loading
Generated 2026-01-01T23:18:10+09:00

All Files ( 77.45% covered at 18.2 hits/line )

80 files in total.
9302 relevant lines, 7204 lines covered and 2098 lines missed. ( 77.45% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
lib/cli/commands/build.rb 81.12 % 282 143 116 27 4.24
lib/cli/commands/generate.rb 79.09 % 449 263 208 55 3.60
lib/cli/commands/generate_xml.rb 59.21 % 158 76 45 31 1.28
lib/cli/commands/hotload.rb 37.38 % 225 107 40 67 1.66
lib/cli/commands/init.rb 68.29 % 206 82 56 26 3.78
lib/cli/commands/setup.rb 73.21 % 103 56 41 15 2.55
lib/cli/main.rb 89.29 % 71 28 25 3 2.89
lib/cli/version.rb 100.00 % 7 3 3 0 1.00
lib/compose/build_cache_manager.rb 74.73 % 173 91 68 23 4.43
lib/compose/components/blurview_component.rb 77.14 % 67 35 27 8 1.97
lib/compose/components/button_component.rb 93.52 % 200 108 101 7 19.09
lib/compose/components/checkbox_component.rb 37.75 % 268 151 57 94 1.94
lib/compose/components/circleimage_component.rb 100.00 % 103 55 55 0 15.89
lib/compose/components/collection_component.rb 94.78 % 397 230 218 12 16.85
lib/compose/components/constraintlayout_component.rb 91.10 % 289 146 133 13 10.42
lib/compose/components/container_component.rb 84.62 % 205 91 77 14 10.36
lib/compose/components/gradientview_component.rb 73.33 % 90 45 33 12 2.60
lib/compose/components/image_component.rb 84.31 % 101 51 43 8 6.86
lib/compose/components/indicator_component.rb 61.67 % 111 60 37 23 1.60
lib/compose/components/networkimage_component.rb 81.13 % 111 53 43 10 3.60
lib/compose/components/progress_component.rb 97.92 % 85 48 47 1 6.83
lib/compose/components/radio_component.rb 92.52 % 365 214 198 16 8.09
lib/compose/components/scrollview_component.rb 100.00 % 89 44 44 0 10.45
lib/compose/components/segment_component.rb 78.92 % 289 166 131 35 18.69
lib/compose/components/selectbox_component.rb 89.17 % 225 120 107 13 14.72
lib/compose/components/slider_component.rb 98.61 % 128 72 71 1 14.65
lib/compose/components/switch_component.rb 38.97 % 242 136 53 83 2.21
lib/compose/components/table_component.rb 100.00 % 176 109 109 0 24.65
lib/compose/components/tabview_component.rb 87.39 % 218 119 104 15 29.50
lib/compose/components/text_component.rb 68.96 % 650 335 231 104 11.50
lib/compose/components/textfield_component.rb 78.71 % 397 202 159 43 16.28
lib/compose/components/textview_component.rb 80.22 % 363 182 146 36 23.99
lib/compose/components/toggle_component.rb 96.23 % 94 53 51 2 8.70
lib/compose/components/web_component.rb 100.00 % 101 52 52 0 26.83
lib/compose/components/webview_component.rb 100.00 % 74 42 42 0 10.02
lib/compose/compose_builder.rb 56.78 % 990 546 310 236 2.77
lib/compose/data_model_updater.rb 86.06 % 506 251 216 35 8.03
lib/compose/generators/cell_generator.rb 96.88 % 358 96 93 3 10.84
lib/compose/generators/converter_generator.rb 82.43 % 462 148 122 26 5.89
lib/compose/generators/dynamic_component_generator.rb 64.90 % 464 151 98 53 2.46
lib/compose/generators/kotlin_component_generator.rb 83.64 % 265 110 92 18 4.69
lib/compose/generators/view_adapter_generator.rb 95.70 % 302 93 89 4 13.31
lib/compose/generators/view_generator.rb 75.18 % 446 137 103 34 10.49
lib/compose/helpers/import_manager.rb 100.00 % 158 23 23 0 4.04
lib/compose/helpers/modifier_builder.rb 71.76 % 703 393 282 111 48.90
lib/compose/helpers/resource_resolver.rb 86.18 % 273 123 106 17 23.80
lib/compose/helpers/visibility_helper.rb 100.00 % 56 31 31 0 15.81
lib/compose/setup/compose_setup.rb 45.30 % 375 117 53 64 1.21
lib/compose/style_loader.rb 100.00 % 77 39 39 0 8.03
lib/core/attribute_validator.rb 78.67 % 650 286 225 61 239.91
lib/core/binding_validator.rb 78.87 % 416 142 112 30 20.40
lib/core/config_manager.rb 98.86 % 195 88 87 1 30.52
lib/core/json_loader.rb 100.00 % 46 17 17 0 7.76
lib/core/logger.rb 100.00 % 33 16 16 0 11.75
lib/core/project_finder.rb 93.22 % 142 59 55 4 3.88
lib/core/resources/color_manager.rb 87.60 % 706 363 318 45 9.93
lib/core/resources/string_manager.rb 61.60 % 479 237 146 91 4.72
lib/core/resources_manager.rb 100.00 % 82 45 45 0 7.33
lib/core/style_loader.rb 100.00 % 67 35 35 0 15.29
lib/core/type_converter.rb 64.98 % 554 237 154 83 21.14
lib/hotloader/ip_monitor.rb 84.78 % 180 92 78 14 3.87
lib/xml/drawable/drawable_generator.rb 92.47 % 185 93 86 7 9.60
lib/xml/drawable/drawable_hash_manager.rb 47.76 % 152 67 32 35 6.78
lib/xml/drawable/ripple_drawable_generator.rb 96.00 % 192 100 96 4 7.31
lib/xml/drawable/shape_drawable_generator.rb 99.06 % 187 106 105 1 6.58
lib/xml/drawable/state_list_drawable_generator.rb 78.18 % 243 110 86 24 6.12
lib/xml/helpers/attribute_mapper.rb 92.11 % 188 76 70 6 13.24
lib/xml/helpers/binding_parser.rb 75.90 % 194 83 63 20 1.96
lib/xml/helpers/component_mapper.rb 93.94 % 152 33 31 2 5.55
lib/xml/helpers/data_binding_helper.rb 100.00 % 27 12 12 0 4.42
lib/xml/helpers/layout_attribute_processor.rb 76.34 % 192 93 71 22 5.38
lib/xml/helpers/mappers/dimension_mapper.rb 100.00 % 49 22 22 0 8.73
lib/xml/helpers/mappers/input_mapper.rb 95.24 % 106 42 40 2 2.57
lib/xml/helpers/mappers/layout_mapper.rb 62.96 % 228 108 68 40 2.93
lib/xml/helpers/mappers/style_mapper.rb 65.55 % 299 119 78 41 3.08
lib/xml/helpers/mappers/text_mapper.rb 93.06 % 161 72 67 5 4.86
lib/xml/helpers/resource_resolver.rb 72.16 % 214 97 70 27 13.90
lib/xml/resources/string_resource_manager.rb 37.80 % 211 82 31 51 3.50
lib/xml/xml_builder.rb 77.78 % 239 135 105 30 2.70
lib/xml/xml_generator.rb 74.16 % 437 209 155 54 3.95

Core ( 79.34% covered at 55.96 hits/line )

11 files in total.
1525 relevant lines, 1210 lines covered and 315 lines missed. ( 79.34% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
lib/core/attribute_validator.rb 78.67 % 650 286 225 61 239.91
lib/core/binding_validator.rb 78.87 % 416 142 112 30 20.40
lib/core/config_manager.rb 98.86 % 195 88 87 1 30.52
lib/core/json_loader.rb 100.00 % 46 17 17 0 7.76
lib/core/logger.rb 100.00 % 33 16 16 0 11.75
lib/core/project_finder.rb 93.22 % 142 59 55 4 3.88
lib/core/resources/color_manager.rb 87.60 % 706 363 318 45 9.93
lib/core/resources/string_manager.rb 61.60 % 479 237 146 91 4.72
lib/core/resources_manager.rb 100.00 % 82 45 45 0 7.33
lib/core/style_loader.rb 100.00 % 67 35 35 0 15.29
lib/core/type_converter.rb 64.98 % 554 237 154 83 21.14

CLI ( 70.45% covered at 3.12 hits/line )

8 files in total.
758 relevant lines, 534 lines covered and 224 lines missed. ( 70.45% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
lib/cli/commands/build.rb 81.12 % 282 143 116 27 4.24
lib/cli/commands/generate.rb 79.09 % 449 263 208 55 3.60
lib/cli/commands/generate_xml.rb 59.21 % 158 76 45 31 1.28
lib/cli/commands/hotload.rb 37.38 % 225 107 40 67 1.66
lib/cli/commands/init.rb 68.29 % 206 82 56 26 3.78
lib/cli/commands/setup.rb 73.21 % 103 56 41 15 2.55
lib/cli/main.rb 89.29 % 71 28 25 3 2.89
lib/cli/version.rb 100.00 % 7 3 3 0 1.00

Compose ( 77.71% covered at 13.61 hits/line )

41 files in total.
5268 relevant lines, 4094 lines covered and 1174 lines missed. ( 77.71% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
lib/compose/build_cache_manager.rb 74.73 % 173 91 68 23 4.43
lib/compose/components/blurview_component.rb 77.14 % 67 35 27 8 1.97
lib/compose/components/button_component.rb 93.52 % 200 108 101 7 19.09
lib/compose/components/checkbox_component.rb 37.75 % 268 151 57 94 1.94
lib/compose/components/circleimage_component.rb 100.00 % 103 55 55 0 15.89
lib/compose/components/collection_component.rb 94.78 % 397 230 218 12 16.85
lib/compose/components/constraintlayout_component.rb 91.10 % 289 146 133 13 10.42
lib/compose/components/container_component.rb 84.62 % 205 91 77 14 10.36
lib/compose/components/gradientview_component.rb 73.33 % 90 45 33 12 2.60
lib/compose/components/image_component.rb 84.31 % 101 51 43 8 6.86
lib/compose/components/indicator_component.rb 61.67 % 111 60 37 23 1.60
lib/compose/components/networkimage_component.rb 81.13 % 111 53 43 10 3.60
lib/compose/components/progress_component.rb 97.92 % 85 48 47 1 6.83
lib/compose/components/radio_component.rb 92.52 % 365 214 198 16 8.09
lib/compose/components/scrollview_component.rb 100.00 % 89 44 44 0 10.45
lib/compose/components/segment_component.rb 78.92 % 289 166 131 35 18.69
lib/compose/components/selectbox_component.rb 89.17 % 225 120 107 13 14.72
lib/compose/components/slider_component.rb 98.61 % 128 72 71 1 14.65
lib/compose/components/switch_component.rb 38.97 % 242 136 53 83 2.21
lib/compose/components/table_component.rb 100.00 % 176 109 109 0 24.65
lib/compose/components/tabview_component.rb 87.39 % 218 119 104 15 29.50
lib/compose/components/text_component.rb 68.96 % 650 335 231 104 11.50
lib/compose/components/textfield_component.rb 78.71 % 397 202 159 43 16.28
lib/compose/components/textview_component.rb 80.22 % 363 182 146 36 23.99
lib/compose/components/toggle_component.rb 96.23 % 94 53 51 2 8.70
lib/compose/components/web_component.rb 100.00 % 101 52 52 0 26.83
lib/compose/components/webview_component.rb 100.00 % 74 42 42 0 10.02
lib/compose/compose_builder.rb 56.78 % 990 546 310 236 2.77
lib/compose/data_model_updater.rb 86.06 % 506 251 216 35 8.03
lib/compose/generators/cell_generator.rb 96.88 % 358 96 93 3 10.84
lib/compose/generators/converter_generator.rb 82.43 % 462 148 122 26 5.89
lib/compose/generators/dynamic_component_generator.rb 64.90 % 464 151 98 53 2.46
lib/compose/generators/kotlin_component_generator.rb 83.64 % 265 110 92 18 4.69
lib/compose/generators/view_adapter_generator.rb 95.70 % 302 93 89 4 13.31
lib/compose/generators/view_generator.rb 75.18 % 446 137 103 34 10.49
lib/compose/helpers/import_manager.rb 100.00 % 158 23 23 0 4.04
lib/compose/helpers/modifier_builder.rb 71.76 % 703 393 282 111 48.90
lib/compose/helpers/resource_resolver.rb 86.18 % 273 123 106 17 23.80
lib/compose/helpers/visibility_helper.rb 100.00 % 56 31 31 0 15.81
lib/compose/setup/compose_setup.rb 45.30 % 375 117 53 64 1.21
lib/compose/style_loader.rb 100.00 % 77 39 39 0 8.03

XML ( 77.64% covered at 5.73 hits/line )

19 files in total.
1659 relevant lines, 1288 lines covered and 371 lines missed. ( 77.64% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
lib/xml/drawable/drawable_generator.rb 92.47 % 185 93 86 7 9.60
lib/xml/drawable/drawable_hash_manager.rb 47.76 % 152 67 32 35 6.78
lib/xml/drawable/ripple_drawable_generator.rb 96.00 % 192 100 96 4 7.31
lib/xml/drawable/shape_drawable_generator.rb 99.06 % 187 106 105 1 6.58
lib/xml/drawable/state_list_drawable_generator.rb 78.18 % 243 110 86 24 6.12
lib/xml/helpers/attribute_mapper.rb 92.11 % 188 76 70 6 13.24
lib/xml/helpers/binding_parser.rb 75.90 % 194 83 63 20 1.96
lib/xml/helpers/component_mapper.rb 93.94 % 152 33 31 2 5.55
lib/xml/helpers/data_binding_helper.rb 100.00 % 27 12 12 0 4.42
lib/xml/helpers/layout_attribute_processor.rb 76.34 % 192 93 71 22 5.38
lib/xml/helpers/mappers/dimension_mapper.rb 100.00 % 49 22 22 0 8.73
lib/xml/helpers/mappers/input_mapper.rb 95.24 % 106 42 40 2 2.57
lib/xml/helpers/mappers/layout_mapper.rb 62.96 % 228 108 68 40 2.93
lib/xml/helpers/mappers/style_mapper.rb 65.55 % 299 119 78 41 3.08
lib/xml/helpers/mappers/text_mapper.rb 93.06 % 161 72 67 5 4.86
lib/xml/helpers/resource_resolver.rb 72.16 % 214 97 70 27 13.90
lib/xml/resources/string_resource_manager.rb 37.80 % 211 82 31 51 3.50
lib/xml/xml_builder.rb 77.78 % 239 135 105 30 2.70
lib/xml/xml_generator.rb 74.16 % 437 209 155 54 3.95

Ungrouped ( 84.78% covered at 3.87 hits/line )

1 files in total.
92 relevant lines, 78 lines covered and 14 lines missed. ( 84.78% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
lib/hotloader/ip_monitor.rb 84.78 % 180 92 78 14 3.87

lib/cli/commands/build.rb

81.12% lines covered

143 relevant lines. 116 lines covered and 27 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'optparse'
  3. 1 require 'json'
  4. 1 require_relative '../../core/config_manager'
  5. 1 require_relative '../../core/project_finder'
  6. 1 require_relative '../../core/logger'
  7. 1 require_relative '../../core/attribute_validator'
  8. 1 require_relative '../../core/binding_validator'
  9. 1 module KjuiTools
  10. 1 module CLI
  11. 1 module Commands
  12. 1 class Build
  13. 1 def run(args)
  14. 9 options = parse_options(args)
  15. # Detect mode
  16. 9 mode = options[:mode] || Core::ConfigManager.get('mode') || 'compose'
  17. # Store validation results
  18. 9 @validation_warnings = []
  19. 9 @validation_errors = 0
  20. 9 case mode
  21. when 'xml', 'all'
  22. 1 build_xml(options)
  23. end
  24. 9 if mode == 'compose' || mode == 'all'
  25. 8 build_compose(options)
  26. end
  27. # Print validation summary if there were warnings
  28. 9 print_validation_summary if options[:validate] != false && @validation_warnings.any?
  29. # Exit with error code if strict mode and there were validation errors
  30. 9 if options[:strict] && @validation_errors > 0
  31. Core::Logger.error "Build failed: #{@validation_errors} validation error(s)"
  32. exit 1
  33. end
  34. end
  35. 1 private
  36. 1 def parse_options(args)
  37. 9 options = {}
  38. 9 OptionParser.new do |opts|
  39. 9 opts.banner = "Usage: kjui build [options]"
  40. 9 opts.on('--mode MODE', ['all', 'xml', 'compose'],
  41. 'Build mode (all, xml, compose)') do |mode|
  42. 2 options[:mode] = mode
  43. end
  44. 9 opts.on('--clean', 'Clean cache before building') do
  45. 1 options[:clean] = true
  46. end
  47. 9 opts.on('--no-validate', 'Skip JSON attribute validation') do
  48. 2 options[:validate] = false
  49. end
  50. 9 opts.on('--strict', 'Fail build on validation errors') do
  51. 1 options[:strict] = true
  52. end
  53. 9 opts.on('-h', '--help', 'Show this help message') do
  54. puts opts
  55. exit
  56. end
  57. end.parse!(args)
  58. # Validation is enabled by default
  59. 9 options[:validate] = true if options[:validate].nil?
  60. 9 options
  61. end
  62. 1 def print_validation_summary
  63. 2 Core::Logger.info "-" * 60
  64. 2 Core::Logger.warn "Validation Summary: #{@validation_warnings.length} warning(s) found"
  65. 2 @validation_warnings.each do |warning|
  66. 9 puts " \e[33m#{warning}\e[0m"
  67. end
  68. end
  69. # Validate a JSON component and all its children recursively
  70. # @param json_data [Hash] The JSON component to validate
  71. # @param validator [AttributeValidator] The validator instance
  72. # @param file_name [String] The name of the file being validated
  73. # @param parent_orientation [String, nil] The orientation of the parent component
  74. 1 def validate_json(json_data, validator, file_name, parent_orientation = nil)
  75. 16 return [] unless json_data.is_a?(Hash)
  76. 16 warnings = validator.validate(json_data, nil, parent_orientation)
  77. # Get this component's orientation for passing to children
  78. 16 current_orientation = json_data['orientation']
  79. # Validate children recursively
  80. 16 children = json_data['child'] || json_data['children'] || []
  81. 16 children = [children] unless children.is_a?(Array)
  82. 16 children.each do |child|
  83. 9 warnings.concat(validate_json(child, validator, file_name, current_orientation)) if child.is_a?(Hash)
  84. end
  85. # Validate sections (for Collection/Table)
  86. # Section headers, footers, and cells are top-level components, so parent_orientation is nil
  87. 16 if json_data['sections'].is_a?(Array)
  88. json_data['sections'].each do |section|
  89. if section.is_a?(Hash)
  90. ['header', 'footer', 'cell'].each do |key|
  91. warnings.concat(validate_json(section[key], validator, file_name, nil)) if section[key].is_a?(Hash)
  92. end
  93. end
  94. end
  95. end
  96. 16 warnings
  97. end
  98. 1 def build_xml(options = {})
  99. Core::Logger.info "Building XML View files..."
  100. # Setup project paths
  101. Core::ProjectFinder.setup_paths
  102. require_relative '../../xml/xml_builder'
  103. builder = Xml::XmlBuilder.new
  104. # Pass validation options to builder
  105. builder.validation_enabled = options[:validate]
  106. builder.validation_callback = ->(file, warnings) {
  107. if warnings.any?
  108. @validation_warnings.concat(warnings.map { |w| "[#{file}] #{w}" })
  109. @validation_errors += warnings.length
  110. end
  111. } if options[:validate]
  112. builder.build(options)
  113. Core::Logger.success "XML build completed!"
  114. end
  115. 1 def build_compose(options = {})
  116. 8 Core::Logger.info "Building Compose files..."
  117. # Setup project paths
  118. 8 Core::ProjectFinder.setup_paths
  119. 8 require_relative '../../compose/compose_builder'
  120. 8 require_relative '../../compose/build_cache_manager'
  121. 8 config = Core::ConfigManager.load_config
  122. 8 source_path = Core::ProjectFinder.get_full_source_path || Dir.pwd
  123. 8 source_directory = config['source_directory'] || 'src/main'
  124. 8 layouts_dir = File.join(source_path, source_directory, config['layouts_directory'] || 'assets/Layouts')
  125. # Initialize cache manager
  126. 8 cache_manager = Compose::BuildCacheManager.new(source_path)
  127. # Clean cache if --clean option is specified
  128. 8 if options[:clean]
  129. 1 Core::Logger.info "Cleaning build cache..."
  130. 1 cache_manager.clean_cache
  131. end
  132. 8 last_updated = cache_manager.load_last_updated
  133. 8 last_including_files = cache_manager.load_last_including_files
  134. 8 style_dependencies = cache_manager.load_style_dependencies
  135. # Process all JSON files in Layouts directory (excluding Resources folder)
  136. 8 json_files = Dir.glob(File.join(layouts_dir, '**/*.json')).reject do |file|
  137. 3 file.include?('/Resources/')
  138. end
  139. 8 if json_files.empty?
  140. 5 Core::Logger.warn "No JSON files found in #{layouts_dir}"
  141. 5 return
  142. end
  143. # Extract resources before processing layouts
  144. 3 require_relative '../../core/resources_manager'
  145. 3 resources_manager = Core::ResourcesManager.new(config, source_path)
  146. 3 resources_manager.extract_resources(json_files)
  147. 3 Core::Logger.info "-" * 60
  148. # Track new includes and style dependencies
  149. 3 new_including_files = {}
  150. 3 new_style_dependencies = {}
  151. # Filter files that need update
  152. 3 files_to_update = []
  153. 3 json_files.each do |json_file|
  154. 3 file_name = File.basename(json_file, '.json')
  155. # Check if file needs update
  156. 3 if cache_manager.needs_update?(json_file, last_updated, layouts_dir, last_including_files, style_dependencies)
  157. 3 files_to_update << json_file
  158. else
  159. # Keep existing includes and style dependencies for unchanged files
  160. new_including_files[file_name] = last_including_files[file_name] if last_including_files[file_name]
  161. new_style_dependencies[file_name] = style_dependencies[file_name] if style_dependencies[file_name]
  162. end
  163. end
  164. # Update data models first (always run to ensure data models are in sync)
  165. 3 require_relative '../../compose/data_model_updater'
  166. 3 data_updater = Compose::DataModelUpdater.new
  167. 3 data_updater.update_data_models(files_to_update)
  168. 3 if files_to_update.empty?
  169. Core::Logger.info "No files need updating (all cached)"
  170. return
  171. end
  172. 3 Core::Logger.info "Updating #{files_to_update.length} of #{json_files.length} files..."
  173. # Initialize validators if validation is enabled
  174. 3 validator = options[:validate] ? Core::AttributeValidator.new(:compose) : nil
  175. 3 binding_validator = options[:validate] ? Core::BindingValidator.new : nil
  176. 3 builder = Compose::ComposeBuilder.new
  177. 3 files_to_update.each do |json_file|
  178. 3 relative_path = Pathname.new(json_file).relative_path_from(Pathname.new(layouts_dir)).to_s
  179. 3 file_name = File.basename(json_file, '.json')
  180. begin
  181. # Read and parse JSON
  182. 3 json_content = File.read(json_file)
  183. 3 json_data = JSON.parse(json_content)
  184. # Validate attributes if enabled
  185. 3 if validator
  186. 2 warnings = validate_json(json_data, validator, file_name)
  187. 2 if warnings.any?
  188. 11 @validation_warnings.concat(warnings.map { |w| "[#{relative_path}] #{w}" })
  189. 2 @validation_errors += warnings.length
  190. 2 Core::Logger.warn " #{warnings.length} attribute warning(s) in #{relative_path}"
  191. end
  192. end
  193. # Validate bindings for business logic
  194. 3 if binding_validator
  195. 2 binding_warnings = binding_validator.validate(json_data, relative_path)
  196. 2 if binding_warnings.any?
  197. @validation_warnings.concat(binding_warnings)
  198. Core::Logger.warn " #{binding_warnings.length} binding warning(s) in #{relative_path}"
  199. end
  200. end
  201. # Extract includes and styles for cache tracking
  202. 3 includes = cache_manager.extract_includes(json_data)
  203. 3 styles = cache_manager.extract_styles(json_data)
  204. 3 new_including_files[file_name] = includes if includes.any?
  205. 3 new_style_dependencies[file_name] = styles if styles.any?
  206. # Build Compose file
  207. 3 Core::Logger.info "Processing: #{relative_path}"
  208. 3 builder.build_file(json_file)
  209. rescue JSON::ParserError => e
  210. Core::Logger.error "Failed to parse #{json_file}: #{e.message}"
  211. rescue => e
  212. Core::Logger.error "Failed to process #{json_file}: #{e.message}"
  213. end
  214. end
  215. # Save cache for next build
  216. 3 cache_manager.save_cache(new_including_files, new_style_dependencies)
  217. 3 Core::Logger.success "Compose build completed!"
  218. end
  219. end
  220. end
  221. end
  222. end

lib/cli/commands/generate.rb

79.09% lines covered

263 relevant lines. 208 lines covered and 55 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'optparse'
  3. 1 require_relative '../../core/config_manager'
  4. 1 require_relative '../../core/project_finder'
  5. 1 module KjuiTools
  6. 1 module CLI
  7. 1 module Commands
  8. 1 class Generate
  9. 1 SUBCOMMANDS = {
  10. 'view' => 'Generate a new view with JSON and binding',
  11. 'partial' => 'Generate a partial view',
  12. 'collection' => 'Generate a collection view',
  13. 'cell' => 'Generate a collection cell view',
  14. 'binding' => 'Generate binding file',
  15. 'converter' => 'Generate a custom component converter',
  16. 'adapter' => 'Generate adapter for existing View (for Dynamic mode)'
  17. }.freeze
  18. 1 def run(args)
  19. # Parse global options first
  20. 25 global_options = parse_global_options(args)
  21. 25 subcommand = args.shift
  22. # Load config to get default mode
  23. 25 config = Core::ConfigManager.load_config
  24. # Use mode from options if provided, otherwise from config, otherwise default to compose
  25. 25 mode = global_options[:mode] || config['mode'] || 'compose'
  26. # If no subcommand, generate all based on mode
  27. 25 if subcommand.nil?
  28. 6 if mode == 'xml'
  29. 1 generate_all_xml_layouts(config)
  30. else
  31. 5 generate_all_compose_views(config)
  32. end
  33. 6 return
  34. end
  35. 19 if subcommand == 'help' || subcommand == '--help' || subcommand == '-h'
  36. 2 show_help
  37. 2 return
  38. end
  39. 17 unless SUBCOMMANDS.key?(subcommand)
  40. # Check if it's a layout name (no subcommand, just generate that layout)
  41. 3 if mode == 'xml' && !subcommand.start_with?('-')
  42. 1 generate_specific_xml_layout(subcommand, args, config)
  43. 1 return
  44. 2 elsif !subcommand.start_with?('-')
  45. # For compose mode, treat it as a layout name and build it
  46. 1 puts "Building layout: #{subcommand}"
  47. 1 generate_specific_compose_layout(subcommand, args, config)
  48. 1 return
  49. end
  50. 1 puts "Unknown generate command: #{subcommand}"
  51. 1 show_help
  52. 1 exit 1
  53. end
  54. 14 case subcommand
  55. when 'view'
  56. 4 generate_view(args, mode)
  57. when 'partial'
  58. 1 generate_partial(args, mode)
  59. when 'collection'
  60. 2 generate_collection(args, mode)
  61. when 'cell'
  62. 3 generate_cell(args, mode)
  63. when 'binding'
  64. 2 generate_binding(args, mode)
  65. when 'converter'
  66. 2 generate_converter(args, mode)
  67. when 'adapter'
  68. generate_adapter(args, mode)
  69. end
  70. end
  71. 1 private
  72. 1 def parse_global_options(args)
  73. 29 options = { mode: nil }
  74. # Look for mode option and remove it from args
  75. 29 args.each_with_index do |arg, index|
  76. 30 if arg == '--mode' || arg == '-m'
  77. 9 if args[index + 1]
  78. 9 options[:mode] = args[index + 1]
  79. 9 args.delete_at(index + 1)
  80. 9 args.delete_at(index)
  81. 9 break
  82. end
  83. 21 elsif arg.start_with?('--mode=')
  84. 2 options[:mode] = arg.split('=', 2)[1]
  85. 2 args.delete_at(index)
  86. 2 break
  87. end
  88. end
  89. 29 options
  90. end
  91. 1 def generate_view(args, mode)
  92. 4 options = parse_view_options(args)
  93. 4 name = args.shift
  94. 4 if name.nil? || name.empty?
  95. 1 puts "Error: View name is required"
  96. 1 puts "Usage: kjui generate view <name> [options]"
  97. 1 exit 1
  98. end
  99. # Setup project paths
  100. 3 Core::ProjectFinder.setup_paths
  101. 3 case mode
  102. when 'xml'
  103. require_relative '../../xml/generators/view_generator'
  104. generator = KjuiTools::Xml::Generators::ViewGenerator.new(name, options)
  105. generator.generate
  106. when 'compose'
  107. 2 require_relative '../../compose/generators/view_generator'
  108. 2 generator = KjuiTools::Compose::Generators::ViewGenerator.new(name, options)
  109. 2 generator.generate
  110. else
  111. 1 puts "Error: Unknown mode: #{mode}"
  112. 1 exit 1
  113. end
  114. end
  115. 1 def generate_partial(args, mode)
  116. 1 name = args.shift
  117. 1 if name.nil? || name.empty?
  118. 1 puts "Error: Partial name is required"
  119. 1 puts "Usage: kjui generate partial <name>"
  120. 1 exit 1
  121. end
  122. case mode
  123. when 'xml'
  124. require_relative '../../xml/generators/partial_generator'
  125. generator = KjuiTools::Xml::Generators::PartialGenerator.new(name)
  126. generator.generate
  127. when 'compose'
  128. require_relative '../../compose/generators/partial_generator'
  129. generator = KjuiTools::Compose::Generators::PartialGenerator.new(name)
  130. generator.generate
  131. end
  132. end
  133. 1 def generate_collection(args, mode)
  134. 2 name = args.shift
  135. 2 if name.nil? || name.empty?
  136. 1 puts "Error: Collection name is required"
  137. 1 puts "Usage: kjui generate collection <name>"
  138. 1 exit 1
  139. end
  140. # Setup project paths
  141. 1 Core::ProjectFinder.setup_paths
  142. 1 case mode
  143. when 'xml'
  144. require_relative '../../xml/generators/collection_generator'
  145. generator = KjuiTools::Xml::Generators::CollectionGenerator.new(name)
  146. generator.generate
  147. when 'compose'
  148. require_relative '../../compose/generators/collection_generator'
  149. generator = KjuiTools::Compose::Generators::CollectionGenerator.new(name)
  150. generator.generate
  151. else
  152. 1 puts "Error: Unknown mode: #{mode}"
  153. 1 exit 1
  154. end
  155. end
  156. 1 def generate_cell(args, mode)
  157. 3 name = args.shift
  158. 3 if name.nil? || name.empty?
  159. 1 puts "Error: Cell name is required"
  160. 1 puts "Usage: kjui generate cell <name>"
  161. 1 exit 1
  162. end
  163. # Setup project paths
  164. 2 Core::ProjectFinder.setup_paths
  165. 2 case mode
  166. when 'xml'
  167. 1 puts "Cell generation is not available in XML mode"
  168. 1 exit 1
  169. when 'compose'
  170. require_relative '../../compose/generators/cell_generator'
  171. generator = KjuiTools::Compose::Generators::CellGenerator.new(name)
  172. generator.generate
  173. else
  174. 1 puts "Error: Unknown mode: #{mode}"
  175. 1 exit 1
  176. end
  177. end
  178. 1 def generate_binding(args, mode)
  179. 2 name = args.shift
  180. 2 if name.nil? || name.empty?
  181. 1 puts "Error: Binding name is required"
  182. 1 puts "Usage: kjui generate binding <name>"
  183. 1 exit 1
  184. end
  185. 1 if mode != 'xml'
  186. 1 puts "Binding generation is only available in XML mode"
  187. 1 exit 1
  188. end
  189. require_relative '../../xml/generators/binding_generator'
  190. generator = KjuiTools::Xml::Generators::BindingGenerator.new(name)
  191. generator.generate
  192. end
  193. 1 def generate_converter(args, mode)
  194. 2 unless mode == 'compose'
  195. 1 puts "Converter generation is only available in Compose mode"
  196. 1 exit 1
  197. end
  198. 1 name = args.shift
  199. 1 unless name
  200. 1 puts "Error: Please provide a component name"
  201. 1 puts "Usage: kjui generate converter <ComponentName> [options]"
  202. 1 puts "Options:"
  203. 1 puts " --container Generate as container component"
  204. 1 puts " --no-container Generate as non-container component"
  205. 1 puts " --attr KEY:TYPE Add attribute (can be used multiple times)"
  206. 1 puts " --binding KEY:TYPE Add binding attribute"
  207. 1 puts
  208. 1 puts "Examples:"
  209. 1 puts " kjui g converter MyCard --container"
  210. 1 puts " kjui g converter StatusBadge --attr text:String --attr color:Color"
  211. 1 puts " kjui g converter DataCard --binding title:String --attr icon:String"
  212. 1 exit 1
  213. end
  214. options = parse_converter_options(args)
  215. require_relative '../../compose/generators/converter_generator'
  216. generator = KjuiTools::Compose::Generators::ConverterGenerator.new(name, options)
  217. generator.generate
  218. end
  219. 1 def parse_converter_options(args)
  220. options = {
  221. 7 is_container: nil,
  222. attributes: {}
  223. }
  224. # Parse flags first
  225. 7 parser = OptionParser.new do |opts|
  226. 7 opts.on('--container', 'Generate as container component') do
  227. 1 options[:is_container] = true
  228. end
  229. 7 opts.on('--no-container', 'Generate as non-container component') do
  230. 1 options[:is_container] = false
  231. end
  232. 7 opts.on('--attr KEY:TYPE', 'Add attribute') do |attr|
  233. 3 key, type = attr.split(':')
  234. 3 if key && type
  235. 3 options[:attributes][key] = type
  236. else
  237. puts "Invalid attribute format. Use KEY:TYPE (e.g., text:String)"
  238. exit 1
  239. end
  240. end
  241. 7 opts.on('--binding KEY:TYPE', 'Add binding attribute') do |attr|
  242. 1 key, type = attr.split(':')
  243. 1 if key && type
  244. # Prefix with @ to indicate binding
  245. 1 options[:attributes]["@#{key}"] = type
  246. else
  247. puts "Invalid binding format. Use KEY:TYPE (e.g., title:String)"
  248. exit 1
  249. end
  250. end
  251. end
  252. 7 parser.parse!(args)
  253. # Parse remaining arguments as attributes (simplified syntax)
  254. 7 args.each do |arg|
  255. 3 if arg.include?(':')
  256. 3 key, type = arg.split(':', 2)
  257. 3 if key && type
  258. # Check if it's a binding (starts with @)
  259. 3 if key.start_with?('@')
  260. 1 options[:attributes][key] = type
  261. else
  262. 2 options[:attributes][key] = type
  263. end
  264. end
  265. end
  266. end
  267. 7 options
  268. end
  269. 1 def parse_view_options(args)
  270. 11 options = {
  271. root: false,
  272. mode: nil,
  273. type: nil,
  274. force: false
  275. }
  276. 11 OptionParser.new do |opts|
  277. 11 opts.on('--root', 'Generate root view/activity') do
  278. 1 options[:root] = true
  279. end
  280. 11 opts.on('--mode MODE', 'Override mode (xml, compose)') do |mode|
  281. 1 options[:mode] = mode
  282. end
  283. 11 opts.on('--type TYPE', 'View type for XML mode (activity, fragment)') do |type|
  284. 1 options[:type] = type
  285. end
  286. 11 opts.on('--activity', 'Generate as Activity (XML mode)') do
  287. 1 options[:type] = 'activity'
  288. end
  289. 11 opts.on('--fragment', 'Generate as Fragment (XML mode)') do
  290. 1 options[:type] = 'fragment'
  291. end
  292. 11 opts.on('-f', '--force', 'Force overwrite existing files') do
  293. 2 options[:force] = true
  294. end
  295. end.parse!(args)
  296. 11 options
  297. end
  298. 1 def generate_all_xml_layouts(config)
  299. require_relative '../../xml/xml_generator'
  300. require_relative '../commands/generate_xml'
  301. puts "Generating all XML layouts..."
  302. CLI::Commands::GenerateXml.run([])
  303. end
  304. 1 def generate_all_compose_views(config)
  305. 5 require_relative '../../compose/compose_builder'
  306. 5 puts "Generating all Compose views..."
  307. # Call the existing Compose builder
  308. 5 system("ruby #{File.join(File.dirname(__FILE__), '../../..', 'bin', 'kjui')} build")
  309. end
  310. 1 def generate_specific_xml_layout(layout_name, args, config)
  311. require_relative '../../xml/xml_generator'
  312. require_relative '../commands/generate_xml'
  313. puts "Generating XML for layout: #{layout_name}"
  314. CLI::Commands::GenerateXml.run([layout_name] + args)
  315. end
  316. 1 def generate_specific_compose_layout(layout_name, args, config)
  317. require_relative '../../compose/compose_builder'
  318. puts "Building Compose layout: #{layout_name}"
  319. # TODO: Implement single layout generation for compose
  320. system("ruby #{File.join(File.dirname(__FILE__), '../../..', 'bin', 'kjui')} build")
  321. end
  322. 1 def generate_adapter(args, mode)
  323. name = args.shift
  324. if name.nil? || name.empty?
  325. puts "Error: View name is required"
  326. puts "Usage: kjui generate adapter <name>"
  327. puts "Example: kjui g adapter Home # Creates HomeViewAdapter for HomeView"
  328. exit 1
  329. end
  330. unless mode == 'compose'
  331. puts "Adapter generation is only available in Compose mode"
  332. exit 1
  333. end
  334. # Setup project paths
  335. Core::ProjectFinder.setup_paths
  336. require_relative '../../compose/generators/view_adapter_generator'
  337. generator = KjuiTools::Compose::Generators::ViewAdapterGenerator.new(name)
  338. generator.generate
  339. end
  340. 1 def show_help
  341. 5 puts "Usage: kjui generate [SUBCOMMAND] [options]"
  342. 5 puts
  343. 5 puts "Global Options:"
  344. 5 puts " --mode, -m MODE Override mode (xml/compose)"
  345. 5 puts " Default: use config.json mode"
  346. 5 puts
  347. 5 puts "When in XML mode:"
  348. 5 puts " kjui generate # Generate all XML layouts"
  349. 5 puts " kjui generate test_menu # Generate specific XML layout"
  350. 5 puts
  351. 5 puts "When in Compose mode:"
  352. 5 puts " kjui generate # Generate all Compose views"
  353. 5 puts
  354. 5 puts "Subcommands:"
  355. 5 SUBCOMMANDS.each do |cmd, desc|
  356. 35 puts " #{cmd.ljust(12)} #{desc}"
  357. end
  358. 5 puts
  359. 5 puts "View Options (XML mode):"
  360. 5 puts " --activity Generate as Activity (default)"
  361. 5 puts " --fragment Generate as Fragment"
  362. 5 puts " --type TYPE Specify type (activity/fragment)"
  363. 5 puts " -f, --force Force overwrite existing files"
  364. 5 puts
  365. 5 puts "Examples:"
  366. 5 puts " kjui g # Generate all (based on config mode)"
  367. 5 puts " kjui g --mode xml # Generate all XML layouts"
  368. 5 puts " kjui g --mode compose # Generate all Compose views"
  369. 5 puts " kjui g view HomeView --mode xml --activity # Generate Activity"
  370. 5 puts " kjui g view ProfileView --mode xml --fragment # Generate Fragment"
  371. 5 puts " kjui g view MainView --mode compose # Generate Compose view"
  372. 5 puts " kjui g converter MyCard --container # Generate custom component"
  373. 5 puts
  374. 5 puts " # View adapter for Dynamic mode (allows TabView to render existing views)"
  375. 5 puts " kjui g adapter Home # Creates HomeViewAdapter for HomeView"
  376. 5 puts " kjui g adapter Search # Creates SearchViewAdapter for SearchView"
  377. end
  378. end
  379. end
  380. end
  381. end

lib/cli/commands/generate_xml.rb

59.21% lines covered

76 relevant lines. 45 lines covered and 31 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 require_relative '../../core/config_manager'
  3. 1 require_relative '../../xml/xml_generator'
  4. 1 module CLI
  5. 1 module Commands
  6. 1 class GenerateXml
  7. 1 def self.run(args)
  8. 5 puts "🔧 KotlinJsonUI XML Generator"
  9. 5 puts "=============================="
  10. # Load configuration
  11. 5 config = ConfigManager.load_config
  12. 5 if config.nil?
  13. 1 puts "❌ Error: config.json not found"
  14. 1 puts "Run 'kjui init --mode xml' first to create configuration"
  15. 1 return 1
  16. end
  17. # Check if XML mode is configured
  18. 4 if config['mode'] != 'xml'
  19. 1 puts "❌ Error: Project is configured for #{config['mode']} mode, not XML"
  20. 1 puts "Run 'kjui init --mode xml' to reconfigure for XML mode"
  21. 1 return 1
  22. end
  23. # Parse arguments
  24. 3 layout_name = nil
  25. 3 force = false
  26. 3 i = 0
  27. 3 while i < args.length
  28. 2 case args[i]
  29. when '--layout', '-l'
  30. layout_name = args[i + 1]
  31. i += 1
  32. when '--force', '-f'
  33. force = true
  34. when '--help', '-h'
  35. 2 show_help
  36. 2 return 0
  37. else
  38. if layout_name.nil? && !args[i].start_with?('-')
  39. layout_name = args[i]
  40. end
  41. end
  42. i += 1
  43. end
  44. 1 if layout_name.nil?
  45. # Generate all layouts
  46. 1 generate_all_layouts(config, force)
  47. else
  48. # Generate specific layout
  49. generate_layout(layout_name, config, force)
  50. end
  51. 1 0
  52. rescue => e
  53. puts "❌ Error: #{e.message}"
  54. puts e.backtrace if ENV['DEBUG']
  55. 1
  56. end
  57. 1 private
  58. 1 def self.generate_all_layouts(config, force)
  59. 1 layouts_dir = File.join(config['project_path'], 'app', 'src', 'main', 'assets', 'Layouts')
  60. 1 unless Dir.exist?(layouts_dir)
  61. puts "❌ Error: Layouts directory not found: #{layouts_dir}"
  62. return
  63. end
  64. 1 json_files = Dir.glob(File.join(layouts_dir, '*.json'))
  65. 1 if json_files.empty?
  66. 1 puts "❌ No JSON layout files found in #{layouts_dir}"
  67. 1 return
  68. end
  69. puts "Found #{json_files.length} layout file(s)"
  70. puts ""
  71. success_count = 0
  72. json_files.each do |json_file|
  73. layout_name = File.basename(json_file, '.json')
  74. if should_generate?(layout_name, config, force)
  75. generator = XmlGenerator::Generator.new(layout_name, config)
  76. if generator.generate
  77. success_count += 1
  78. end
  79. else
  80. puts "⏭️ Skipping #{layout_name} (up to date)"
  81. end
  82. end
  83. puts ""
  84. puts "✅ Successfully generated #{success_count} XML layout(s)"
  85. end
  86. 1 def self.generate_layout(layout_name, config, force)
  87. # Remove .json extension if present
  88. layout_name = layout_name.sub(/\.json$/, '')
  89. if should_generate?(layout_name, config, force)
  90. generator = XmlGenerator::Generator.new(layout_name, config)
  91. if generator.generate
  92. puts "✅ Successfully generated XML for #{layout_name}"
  93. else
  94. puts "❌ Failed to generate XML for #{layout_name}"
  95. end
  96. else
  97. puts "⏭️ Layout #{layout_name} is up to date (use --force to regenerate)"
  98. end
  99. end
  100. 1 def self.should_generate?(layout_name, config, force)
  101. 5 return true if force
  102. # Check modification times
  103. 4 json_file = File.join(config['project_path'], 'app', 'src', 'main', 'assets', 'Layouts', "#{layout_name}.json")
  104. 4 xml_file = File.join(config['project_path'], 'app', 'src', 'main', 'res', 'layout', "#{layout_name.downcase}.xml")
  105. 4 return true unless File.exist?(xml_file)
  106. 2 return true unless File.exist?(json_file)
  107. 2 File.mtime(json_file) > File.mtime(xml_file)
  108. end
  109. 1 def self.show_help
  110. 8 puts <<~HELP
  111. Usage: kjui generate-xml [layout_name] [options]
  112. Generate Android XML layouts from JSON files
  113. Arguments:
  114. layout_name Name of the layout to generate (optional)
  115. If not specified, generates all layouts
  116. Options:
  117. -l, --layout <name> Specify layout name
  118. -f, --force Force regeneration even if up to date
  119. -h, --help Show this help message
  120. Examples:
  121. kjui generate-xml # Generate all layouts
  122. kjui generate-xml test_menu # Generate specific layout
  123. kjui generate-xml -f # Force regenerate all
  124. kjui generate-xml test_menu -f # Force regenerate specific layout
  125. HELP
  126. end
  127. end
  128. end
  129. end

lib/cli/commands/hotload.rb

37.38% lines covered

107 relevant lines. 40 lines covered and 67 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'fileutils'
  3. 1 require 'json'
  4. 1 require 'open3'
  5. 1 require_relative '../../hotloader/ip_monitor'
  6. 1 module KjuiTools
  7. 1 module CLI
  8. 1 module Commands
  9. 1 class Hotload
  10. 1 def self.run(args)
  11. 6 command = args.first
  12. 6 case command
  13. when 'start', 'listen'
  14. 2 start_hotloader
  15. when 'stop'
  16. 1 stop_hotloader
  17. when 'status'
  18. 1 show_status
  19. else
  20. 2 show_help
  21. end
  22. end
  23. 1 private
  24. 1 def self.start_hotloader
  25. puts "Starting KotlinJsonUI HotLoader..."
  26. puts "================================="
  27. # Check if Node.js is installed
  28. unless system('which node > /dev/null 2>&1')
  29. puts "Error: Node.js is not installed. Please install Node.js first."
  30. puts "Visit: https://nodejs.org/"
  31. exit 1
  32. end
  33. # Find project root
  34. project_root = find_project_root
  35. hotloader_dir = File.join(File.dirname(__FILE__), '../../hotloader')
  36. # Load config to get port
  37. config_path = File.join(project_root, 'kjui.config.json')
  38. config = File.exist?(config_path) ? JSON.parse(File.read(config_path)) : {}
  39. port = config.dig('hotloader', 'port') || 8081
  40. # Install npm dependencies if needed
  41. Dir.chdir(hotloader_dir) do
  42. unless Dir.exist?('node_modules')
  43. puts "Installing dependencies..."
  44. system('npm install')
  45. end
  46. end
  47. # Kill any existing processes on the port
  48. kill_port_process(port)
  49. # Start IP monitor
  50. ip_monitor = KjuiTools::Hotloader::IpMonitor.new(project_root)
  51. ip_monitor.start
  52. # Get current IP
  53. ip = get_local_ip
  54. puts "\nLocal IP: #{ip}"
  55. puts "Port: #{port}"
  56. # Start Node.js server
  57. puts "\nStarting server..."
  58. Dir.chdir(hotloader_dir) do
  59. ENV['HOST'] = '0.0.0.0'
  60. ENV['PORT'] = port.to_s
  61. ENV['PROJECT_ROOT'] = project_root
  62. # Start server in foreground
  63. system('node server.js')
  64. end
  65. # Stop IP monitor when server stops
  66. ip_monitor.stop
  67. end
  68. 1 def self.stop_hotloader
  69. puts "Stopping KotlinJsonUI HotLoader..."
  70. # Load config to get port
  71. project_root = find_project_root
  72. config_path = File.join(project_root, 'kjui.config.json')
  73. config = File.exist?(config_path) ? JSON.parse(File.read(config_path)) : {}
  74. port = config.dig('hotloader', 'port') || 8081
  75. # Kill Node.js server
  76. kill_port_process(port)
  77. # Kill any node processes running server.js
  78. system("pkill -f 'node.*server.js'")
  79. puts "HotLoader stopped"
  80. end
  81. 1 def self.show_status
  82. puts "KotlinJsonUI HotLoader Status"
  83. puts "============================="
  84. # Load config to get port
  85. project_root = find_project_root
  86. config_path = File.join(project_root, 'kjui.config.json')
  87. config = File.exist?(config_path) ? JSON.parse(File.read(config_path)) : {}
  88. port = config.dig('hotloader', 'port') || 8081
  89. # Check if server is running
  90. if port_in_use?(port)
  91. puts "Status: ✅ Running"
  92. # Try to get server info
  93. begin
  94. require 'net/http'
  95. require 'uri'
  96. ip = get_local_ip
  97. uri = URI.parse("http://#{ip}:#{port}/")
  98. response = Net::HTTP.get_response(uri)
  99. if response.code == '200'
  100. info = JSON.parse(response.body)
  101. puts "Project: #{info['projectRoot']}"
  102. puts "Connected clients: #{info['connectedClients']}"
  103. end
  104. rescue => e
  105. puts "Server is running but couldn't get details"
  106. end
  107. else
  108. puts "Status: ❌ Not running"
  109. end
  110. # Show configuration
  111. if config['hotloader']
  112. puts "\nConfiguration:"
  113. puts "IP: #{config['hotloader']['ip']}"
  114. puts "Port: #{config['hotloader']['port']}"
  115. puts "Enabled: #{config['hotloader']['enabled']}"
  116. end
  117. end
  118. 1 def self.show_help
  119. 5 puts <<~HELP
  120. KotlinJsonUI HotLoader Commands
  121. ===============================
  122. Usage: kjui hotload <command>
  123. Commands:
  124. start, listen - Start the hotloader server
  125. stop - Stop the hotloader server
  126. status - Show server status
  127. The hotloader enables real-time UI updates during development.
  128. It watches for changes in Layouts/ and Styles/ directories and
  129. automatically rebuilds and reloads the UI in your Android app.
  130. Example:
  131. kjui hotload start # Start development server
  132. kjui hotload stop # Stop server
  133. kjui hotload status # Check if server is running
  134. HELP
  135. end
  136. 1 def self.find_project_root(start_path = Dir.pwd)
  137. 4 current = start_path
  138. 4 while current != '/'
  139. # Check for kjui.config.json
  140. 5 if File.exist?(File.join(current, 'kjui.config.json'))
  141. 2 return current
  142. end
  143. # Check for Android project files
  144. 3 if File.exist?(File.join(current, 'build.gradle.kts')) ||
  145. File.exist?(File.join(current, 'settings.gradle.kts'))
  146. 2 return current
  147. end
  148. 1 current = File.dirname(current)
  149. end
  150. Dir.pwd
  151. end
  152. 1 def self.get_local_ip
  153. 1 require 'socket'
  154. # Try common interface names
  155. 1 interfaces = ['wlan0', 'wlp2s0', 'en0', 'en1', 'eth0']
  156. 1 interfaces.each do |interface|
  157. 3 Socket.getifaddrs.each do |ifaddr|
  158. 107 if ifaddr.name == interface && ifaddr.addr&.ipv4?
  159. 1 ip = ifaddr.addr.ip_address
  160. 1 return ip unless ip.start_with?('127.')
  161. end
  162. end
  163. end
  164. # Fallback
  165. Socket.ip_address_list.find { |ai| ai.ipv4? && !ai.ipv4_loopback? }&.ip_address || '127.0.0.1'
  166. rescue
  167. '127.0.0.1'
  168. end
  169. 1 def self.port_in_use?(port)
  170. 1 system("lsof -i:#{port} > /dev/null 2>&1")
  171. end
  172. 1 def self.kill_port_process(port)
  173. if port_in_use?(port)
  174. puts "Killing existing process on port #{port}..."
  175. system("lsof -ti:#{port} | xargs kill -9 2>/dev/null")
  176. sleep 1
  177. end
  178. end
  179. end
  180. end
  181. end
  182. end

lib/cli/commands/init.rb

68.29% lines covered

82 relevant lines. 56 lines covered and 26 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'optparse'
  3. 1 require 'fileutils'
  4. 1 require 'json'
  5. 1 require_relative '../../core/config_manager'
  6. 1 require_relative '../../core/project_finder'
  7. 1 module KjuiTools
  8. 1 module CLI
  9. 1 module Commands
  10. 1 class Init
  11. 1 def run(args)
  12. 10 options = parse_options(args)
  13. # Check if MODE file exists (set by installer)
  14. 9 installer_mode = nil
  15. 9 mode_file = File.join(File.dirname(__FILE__), '../../../../MODE')
  16. 9 if File.exist?(mode_file)
  17. installer_mode = File.read(mode_file).strip
  18. end
  19. # Detect or use specified mode
  20. 9 mode = options[:mode] || installer_mode || Core::ConfigManager.detect_mode
  21. 9 puts "Initializing KotlinJsonUI project in #{mode} mode..."
  22. # Create config file only - directories will be created by 'setup' command
  23. 9 create_config_file(mode)
  24. 9 puts "Initialization complete!"
  25. 9 puts
  26. 9 puts "Next steps:"
  27. 9 puts " 1. Edit kjui.config.json to customize paths if needed"
  28. 9 puts " 2. Run 'kjui setup' to create directories and base files"
  29. 9 puts " 3. Run 'kjui g view HomeView' to generate your first view"
  30. end
  31. 1 private
  32. 1 def parse_options(args)
  33. 10 options = {}
  34. 10 OptionParser.new do |opts|
  35. 10 opts.banner = "Usage: kjui init [options]"
  36. 10 opts.on('--mode MODE', ['all', 'xml', 'compose'],
  37. 'Initialize mode (all, xml, compose)') do |mode|
  38. 8 options[:mode] = mode
  39. end
  40. 10 opts.on('-h', '--help', 'Show this help message') do
  41. 1 puts opts
  42. 1 exit
  43. end
  44. end.parse!(args)
  45. 9 options
  46. end
  47. 1 def create_config_file(mode)
  48. 9 config_file = 'kjui.config.json'
  49. 9 if File.exist?(config_file)
  50. 1 puts "Config file already exists: #{config_file}"
  51. # Check if source_directory needs to be updated
  52. 1 existing_config = JSON.parse(File.read(config_file))
  53. 1 if existing_config['source_directory'].to_s.empty?
  54. Core::ProjectFinder.setup_paths
  55. # Auto-detect source directory without checking config
  56. project_dir = Core::ProjectFinder.project_dir
  57. # If project_dir is nil, fallback to finding gradle files
  58. if project_dir.nil?
  59. gradle_file = Dir.glob('build.gradle*').first || Dir.glob('../build.gradle*').first
  60. project_dir = gradle_file ? File.dirname(File.expand_path(gradle_file)) : Dir.pwd
  61. end
  62. common_names = ['app/src/main', 'src/main', 'src', File.basename(project_dir)]
  63. source_dir = nil
  64. common_names.each do |name|
  65. path = File.join(project_dir, name)
  66. if Dir.exist?(path)
  67. source_dir = name
  68. break
  69. end
  70. end
  71. if source_dir && !source_dir.empty?
  72. existing_config['source_directory'] = source_dir
  73. File.write(config_file, JSON.pretty_generate(existing_config))
  74. puts "Updated source_directory to: #{source_dir}"
  75. end
  76. end
  77. 1 return
  78. end
  79. # Find project info
  80. 8 Core::ProjectFinder.setup_paths
  81. # Get project name from settings.gradle or current directory
  82. 8 project_name = get_project_name_from_gradle || File.basename(Dir.pwd)
  83. # Create base config based on mode
  84. 8 if mode == 'compose'
  85. # Detect package name
  86. 5 package_name = Core::ProjectFinder.package_name
  87. # Compose-specific config with appropriate defaults
  88. # Detect if we're in a module or main app
  89. 5 source_dir = if Dir.exist?('src/main')
  90. 'src/main'
  91. 5 elsif Dir.exist?('app/src/main')
  92. 'app/src/main'
  93. else
  94. 5 Core::ProjectFinder.find_source_directory || 'src/main'
  95. end
  96. config = {
  97. 5 'mode' => mode,
  98. 'project_name' => project_name,
  99. 'source_directory' => source_dir,
  100. 'layouts_directory' => 'assets/Layouts',
  101. 'styles_directory' => 'assets/Styles',
  102. 'data_directory' => "kotlin/#{package_name.gsub('.', '/')}/data",
  103. 'viewmodel_directory' => "kotlin/#{package_name.gsub('.', '/')}/viewmodels",
  104. 'view_directory' => "kotlin/#{package_name.gsub('.', '/')}/views",
  105. 'extension_directory' => "kotlin/#{package_name.gsub('.', '/')}/extensions",
  106. 'adapter_directory' => "kotlin/#{package_name.gsub('.', '/')}/adapters",
  107. 'resource_manager_directory' => "kotlin/#{package_name.gsub('.', '/')}/generated",
  108. 'package_name' => package_name,
  109. 'string_files' => [
  110. 'res/values/strings.xml',
  111. 'res/values-ja/strings.xml'
  112. ],
  113. 'use_network' => true, # Compose mode can use network for API calls
  114. 'hotloader' => {
  115. 'ip' => '127.0.0.1',
  116. 'port' => 8081,
  117. 'watch_directories' => ['assets/Layouts', 'assets/Styles']
  118. }
  119. }
  120. else
  121. # XML mode or all mode config
  122. config = {
  123. 3 'mode' => mode,
  124. 'project_name' => project_name,
  125. 'project_file_name' => project_name,
  126. 'source_directory' => Core::ProjectFinder.find_source_directory || 'app/src/main',
  127. 'layouts_directory' => 'res/raw/layouts',
  128. 'styles_directory' => 'res/raw/styles',
  129. 'view_directory' => 'java/com/example/app/ui',
  130. 'data_directory' => 'java/com/example/app/data',
  131. 'viewmodel_directory' => 'java/com/example/app/viewmodel',
  132. 'bindings_directory' => 'java/com/example/app/bindings',
  133. 'extension_directory' => 'java/com/example/app/extensions',
  134. 'adapter_directory' => 'java/com/example/app/adapters',
  135. 'resource_manager_directory' => 'java/com/example/app/generated',
  136. 'string_files' => [
  137. 'res/values/strings.xml',
  138. 'res/values-ja/strings.xml'
  139. ],
  140. 'use_network' => true,
  141. 'hotloader' => {
  142. 'ip' => '127.0.0.1',
  143. 'port' => 8081,
  144. 'watch_directories' => ['res/raw/layouts', 'res/raw/styles']
  145. }
  146. }
  147. # Add Compose config if mode is 'all'
  148. 3 if mode == 'all'
  149. config['compose'] = {
  150. 'output_directory' => 'java/com/example/app/generated'
  151. }
  152. end
  153. end
  154. 8 File.write(config_file, JSON.pretty_generate(config))
  155. 8 puts "Created config file: #{config_file}"
  156. end
  157. 1 def get_project_name_from_gradle
  158. # Try settings.gradle.kts first
  159. 8 if File.exist?('settings.gradle.kts')
  160. content = File.read('settings.gradle.kts')
  161. if content =~ /rootProject\.name\s*=\s*["']([^"']+)["']/
  162. return $1
  163. end
  164. end
  165. # Try settings.gradle
  166. 8 if File.exist?('settings.gradle')
  167. content = File.read('settings.gradle')
  168. if content =~ /rootProject\.name\s*=\s*["']([^"']+)["']/
  169. return $1
  170. end
  171. end
  172. nil
  173. end
  174. end
  175. end
  176. end
  177. end

lib/cli/commands/setup.rb

73.21% lines covered

56 relevant lines. 41 lines covered and 15 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'optparse'
  3. 1 require 'fileutils'
  4. 1 require_relative '../../core/config_manager'
  5. 1 require_relative '../../core/project_finder'
  6. 1 module KjuiTools
  7. 1 module CLI
  8. 1 module Commands
  9. 1 class Setup
  10. 1 def run(args)
  11. 6 options = parse_options(args)
  12. # Check and install dependencies first
  13. 6 ensure_dependencies_installed
  14. # Setup project paths
  15. 6 Core::ProjectFinder.setup_paths
  16. # Load config to determine mode
  17. 6 config = Core::ConfigManager.load_config
  18. 6 mode = config['mode'] || 'compose'
  19. 6 puts "Setting up KotlinJsonUI project in #{mode} mode..."
  20. # Setup based on mode
  21. 6 case mode
  22. when 'compose'
  23. 3 setup_compose_project
  24. when 'xml'
  25. 2 setup_xml_project
  26. when 'all'
  27. 1 setup_xml_project
  28. 1 setup_compose_project
  29. end
  30. 6 puts "\nSetup complete!"
  31. 6 if mode == 'compose'
  32. 3 puts "Next steps:"
  33. 3 puts " 1. Create your layouts in the assets/Layouts directory"
  34. 3 puts " 2. Run 'kjui convert' to generate Compose code"
  35. 3 puts " 3. Build your project with Gradle"
  36. else
  37. 3 puts "Next steps:"
  38. 3 puts " 1. Run 'kjui g view HomeView' to generate your first view"
  39. 3 puts " 2. Build your project with Gradle"
  40. end
  41. end
  42. 1 private
  43. 1 def ensure_dependencies_installed
  44. # Check if Gemfile.lock exists
  45. kjui_tools_dir = File.expand_path('../../../..', __FILE__)
  46. gemfile_lock = File.join(kjui_tools_dir, 'Gemfile.lock')
  47. unless File.exist?(gemfile_lock)
  48. puts "Installing kjui_tools dependencies..."
  49. Dir.chdir(kjui_tools_dir) do
  50. success = system('bundle install')
  51. unless success
  52. puts "Warning: Failed to install some dependencies"
  53. puts "You may need to install them manually with: cd kjui_tools && bundle install"
  54. end
  55. end
  56. end
  57. end
  58. 1 def parse_options(args)
  59. 9 options = {}
  60. 9 OptionParser.new do |opts|
  61. 9 opts.banner = "Usage: kjui setup [options]"
  62. 9 opts.on('-h', '--help', 'Show this help message') do
  63. 2 puts opts
  64. 2 exit
  65. end
  66. end.parse!(args)
  67. 7 options
  68. end
  69. 1 def setup_compose_project
  70. require_relative '../../compose/setup/compose_setup'
  71. # Use the Compose-specific setup
  72. setup = ::KjuiTools::Compose::Setup::ComposeSetup.new(Core::ProjectFinder.project_file_path)
  73. setup.run_full_setup
  74. end
  75. 1 def setup_xml_project
  76. require_relative '../../xml/setup/xml_setup'
  77. # Use the XML-specific setup
  78. setup = ::KjuiTools::Xml::Setup::XmlSetup.new(Core::ProjectFinder.project_file_path)
  79. setup.run_full_setup
  80. end
  81. end
  82. end
  83. end
  84. end

lib/cli/main.rb

89.29% lines covered

28 relevant lines. 25 lines covered and 3 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative 'version'
  3. 1 require_relative 'commands/init'
  4. 1 require_relative 'commands/setup'
  5. 1 require_relative 'commands/build'
  6. 1 require_relative 'commands/generate'
  7. 1 require_relative 'commands/hotload'
  8. 1 module KjuiTools
  9. 1 module CLI
  10. 1 class Main
  11. 1 def self.run(args)
  12. 19 command = args.shift
  13. 19 case command
  14. when 'init'
  15. 1 Commands::Init.new.run(args)
  16. when 'setup'
  17. 1 Commands::Setup.new.run(args)
  18. when 'generate', 'g'
  19. 2 Commands::Generate.new.run(args)
  20. when 'build', 'b'
  21. 2 Commands::Build.new.run(args)
  22. when 'hotload', 'hot'
  23. 2 Commands::Hotload.run(args)
  24. when 'watch', 'w'
  25. 2 puts "Watch command not yet implemented"
  26. when 'version', 'v', '--version', '-v'
  27. 4 puts "KotlinJsonUI Tools version #{VERSION}"
  28. when 'help', '--help', '-h', nil
  29. 4 show_help
  30. else
  31. 1 puts "Unknown command: #{command}"
  32. 1 show_help
  33. 1 exit 1
  34. end
  35. rescue StandardError => e
  36. puts "Error: #{e.message}"
  37. puts e.backtrace if ENV['DEBUG']
  38. exit 1
  39. end
  40. 1 def self.show_help
  41. 11 puts <<~HELP
  42. KotlinJsonUI Tools - JSON-based UI framework for Android
  43. Usage: kjui <command> [options]
  44. Commands:
  45. init Initialize a new KotlinJsonUI project
  46. generate, g Generate views and components
  47. setup Set up project dependencies
  48. build, b Build the project
  49. hotload, hot Start/stop hotload server for real-time updates
  50. watch, w Watch for file changes
  51. version, v Show version information
  52. help Show this help message
  53. Examples:
  54. kjui init --mode compose Initialize a Jetpack Compose project
  55. kjui init --mode xml Initialize an XML-based project
  56. kjui g view HomeView Generate a new view
  57. For more information on a specific command:
  58. kjui <command> --help
  59. HELP
  60. end
  61. end
  62. end
  63. end

lib/cli/version.rb

100.0% lines covered

3 relevant lines. 3 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module KjuiTools
  3. 1 module CLI
  4. 1 VERSION = '1.0.0'
  5. end
  6. end

lib/compose/build_cache_manager.rb

74.73% lines covered

91 relevant lines. 68 lines covered and 23 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'json'
  3. 1 require 'fileutils'
  4. 1 require 'pathname'
  5. 1 require 'digest'
  6. 1 module KjuiTools
  7. 1 module Compose
  8. 1 class BuildCacheManager
  9. 1 def initialize(source_path)
  10. 26 @source_path = source_path
  11. 26 @cache_dir = File.join(source_path, '.kjui_cache')
  12. 26 @last_updated_file = File.join(@cache_dir, 'last_updated.json')
  13. 26 @including_files_cache = File.join(@cache_dir, 'including_files.json')
  14. 26 @style_dependencies_cache = File.join(@cache_dir, 'style_dependencies.json')
  15. # Create cache directory if it doesn't exist
  16. 26 FileUtils.mkdir_p(@cache_dir) unless File.exist?(@cache_dir)
  17. end
  18. 1 def load_last_updated
  19. 11 return {} unless File.exist?(@last_updated_file)
  20. 2 JSON.parse(File.read(@last_updated_file))
  21. rescue JSON::ParserError
  22. 1 {}
  23. end
  24. 1 def load_last_including_files
  25. 10 return {} unless File.exist?(@including_files_cache)
  26. 1 JSON.parse(File.read(@including_files_cache))
  27. rescue JSON::ParserError
  28. {}
  29. end
  30. 1 def load_style_dependencies
  31. 9 return {} unless File.exist?(@style_dependencies_cache)
  32. JSON.parse(File.read(@style_dependencies_cache))
  33. rescue JSON::ParserError
  34. {}
  35. end
  36. 1 def needs_update?(json_file, last_updated, layouts_dir, last_including_files, style_dependencies)
  37. 6 file_name = File.basename(json_file, '.json')
  38. # Check if file exists in last_updated
  39. 6 return true unless last_updated[file_name]
  40. # Check if file has been modified
  41. 2 file_mtime = File.mtime(json_file).to_i
  42. 2 return true if file_mtime > last_updated[file_name]['mtime'].to_i
  43. # Check if any included files have been modified
  44. 1 if last_including_files[file_name]
  45. last_including_files[file_name].each do |included_file|
  46. included_path = File.join(layouts_dir, "#{included_file}.json")
  47. if File.exist?(included_path)
  48. included_mtime = File.mtime(included_path).to_i
  49. return true if included_mtime > last_updated[file_name]['mtime'].to_i
  50. end
  51. end
  52. end
  53. # Check if any style dependencies have been modified
  54. 1 if style_dependencies[file_name]
  55. styles_dir = File.join(@source_path, 'assets', 'Styles')
  56. style_dependencies[file_name].each do |style_file|
  57. style_path = File.join(styles_dir, "#{style_file}.json")
  58. if File.exist?(style_path)
  59. style_mtime = File.mtime(style_path).to_i
  60. return true if style_mtime > last_updated[file_name]['mtime'].to_i
  61. end
  62. end
  63. end
  64. # Check if any file that includes this file has been modified
  65. 1 last_including_files.each do |parent_file, includes|
  66. if includes && includes.include?(file_name)
  67. parent_path = File.join(layouts_dir, "#{parent_file}.json")
  68. if File.exist?(parent_path)
  69. parent_mtime = File.mtime(parent_path).to_i
  70. return true if parent_mtime > last_updated[file_name]['mtime'].to_i
  71. end
  72. end
  73. end
  74. 1 false
  75. end
  76. 1 def extract_includes(json_data, includes = Set.new)
  77. 16 if json_data.is_a?(Hash)
  78. # Check for include
  79. 15 if json_data['include']
  80. 6 includes.add(json_data['include'])
  81. end
  82. # Process children
  83. 15 if json_data['child']
  84. 6 if json_data['child'].is_a?(Array)
  85. 4 json_data['child'].each do |child|
  86. 5 extract_includes(child, includes)
  87. end
  88. else
  89. 2 extract_includes(json_data['child'], includes)
  90. end
  91. end
  92. 1 elsif json_data.is_a?(Array)
  93. 1 json_data.each do |item|
  94. 2 extract_includes(item, includes)
  95. end
  96. end
  97. 16 includes.to_a
  98. end
  99. 1 def extract_styles(json_data, styles = Set.new)
  100. 10 if json_data.is_a?(Hash)
  101. # Check for style attribute
  102. 10 if json_data['style']
  103. 3 styles.add(json_data['style'])
  104. end
  105. # Process children
  106. 10 if json_data['child']
  107. 4 if json_data['child'].is_a?(Array)
  108. 4 json_data['child'].each do |child|
  109. 5 extract_styles(child, styles)
  110. end
  111. else
  112. extract_styles(json_data['child'], styles)
  113. end
  114. end
  115. elsif json_data.is_a?(Array)
  116. json_data.each do |item|
  117. extract_styles(item, styles)
  118. end
  119. end
  120. 10 styles.to_a
  121. end
  122. 1 def save_cache(including_files, style_dependencies)
  123. # Update last_updated with current timestamps
  124. 4 last_updated = {}
  125. # Get all processed files
  126. 4 all_files = (including_files.keys + style_dependencies.keys).uniq
  127. 4 all_files.each do |file_name|
  128. 1 layouts_dir = File.join(@source_path, 'assets', 'Layouts')
  129. 1 json_file = File.join(layouts_dir, "#{file_name}.json")
  130. 1 if File.exist?(json_file)
  131. 1 last_updated[file_name] = {
  132. 'mtime' => File.mtime(json_file).to_i,
  133. 'hash' => Digest::MD5.hexdigest(File.read(json_file))
  134. }
  135. end
  136. end
  137. # Save all cache files
  138. 4 File.write(@last_updated_file, JSON.pretty_generate(last_updated))
  139. 4 File.write(@including_files_cache, JSON.pretty_generate(including_files))
  140. 4 File.write(@style_dependencies_cache, JSON.pretty_generate(style_dependencies))
  141. end
  142. 1 def clean_cache
  143. 2 FileUtils.rm_rf(@cache_dir)
  144. 2 FileUtils.mkdir_p(@cache_dir)
  145. end
  146. end
  147. end
  148. end

lib/compose/components/blurview_component.rb

77.14% lines covered

35 relevant lines. 27 lines covered and 8 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class BlurviewComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. # BlurView in Compose requires a special modifier or library
  10. # For now, we'll create a semi-transparent overlay as a fallback
  11. 3 code = indent("Box(", depth)
  12. # Build modifiers
  13. 3 modifiers = []
  14. 3 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  15. 3 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  16. 3 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  17. # Add blur effect
  18. 3 blur_radius = json_data['blurRadius'] || 10
  19. # Try to use real blur modifier (available in Compose 1.3+)
  20. 3 required_imports&.add(:blur)
  21. 3 modifiers << ".blur(#{blur_radius}.dp)"
  22. # Background color
  23. 3 if json_data['backgroundColor']
  24. bg_color = json_data['backgroundColor']
  25. opacity = json_data['opacity'] || 0.8
  26. modifiers << ".background(Helpers::ResourceResolver.process_color('#{bg_color}', required_imports).copy(alpha = #{opacity}f))"
  27. end
  28. # Add corner radius if specified
  29. 3 if json_data['cornerRadius']
  30. required_imports&.add(:shape)
  31. modifiers << ".clip(RoundedCornerShape(#{json_data['cornerRadius']}.dp))"
  32. end
  33. 3 modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
  34. 3 modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
  35. 3 code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
  36. 3 code += "\n" + indent(") {", depth)
  37. # Process children
  38. 3 children = json_data['child'] || []
  39. 3 children = [children] unless children.is_a?(Array)
  40. # Return structure for parent to process children
  41. 3 { code: code, children: children, closing: "\n" + indent("}", depth), json_data: json_data }
  42. end
  43. 1 private
  44. 1 def self.indent(text, level)
  45. 9 return text if level == 0
  46. spaces = ' ' * level
  47. text.split("\n").map { |line|
  48. line.empty? ? line : spaces + line
  49. }.join("\n")
  50. end
  51. end
  52. end
  53. end
  54. end

lib/compose/components/button_component.rb

93.52% lines covered

108 relevant lines. 101 lines covered and 7 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class ButtonComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. # Button uses 'text' attribute per SwiftJsonUI spec
  10. 26 text = Helpers::ResourceResolver.process_text(json_data['text'] || 'Button', required_imports)
  11. 26 code = indent("Button(", depth)
  12. # Handle click events
  13. # onclick (lowercase) -> selector format (string only)
  14. # onClick (camelCase) -> binding format only (@{functionName})
  15. 26 if json_data['onclick']
  16. 1 handler_call = Helpers::ModifierBuilder.get_event_handler_call(json_data['onclick'], is_camel_case: false)
  17. 1 code += "\n" + indent("onClick = { #{handler_call} }", depth + 1)
  18. 25 elsif json_data['onClick']
  19. handler_call = Helpers::ModifierBuilder.get_event_handler_call(json_data['onClick'], is_camel_case: true)
  20. code += "\n" + indent("onClick = { #{handler_call} }", depth + 1)
  21. else
  22. 25 code += "\n" + indent("onClick = { }", depth + 1)
  23. end
  24. # Build modifiers (only margins, size, and weight, not padding)
  25. 26 modifiers = []
  26. 26 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  27. 26 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  28. 26 modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
  29. # Format modifiers only if there are modifiers
  30. 26 if modifiers.any?
  31. 3 code += ","
  32. 3 code += Helpers::ModifierBuilder.format(modifiers, depth)
  33. end
  34. # Add shape with cornerRadius (always set to match dynamic defaults)
  35. 26 required_imports&.add(:shape)
  36. 26 required_imports&.add(:configuration)
  37. 26 corner_radius = json_data['cornerRadius'] || 'Configuration.Button.defaultCornerRadius'
  38. 26 code += ",\n" + indent("shape = RoundedCornerShape(#{corner_radius}.dp)", depth + 1)
  39. # Add contentPadding for internal padding
  40. # Support both 'padding' (number), 'paddings' (array), and individual padding attributes
  41. 26 padding_data = json_data['paddings'] || json_data['padding']
  42. 26 if padding_data || json_data['paddingTop'] || json_data['paddingBottom'] ||
  43. json_data['paddingLeft'] || json_data['paddingRight'] || json_data['paddingStart'] ||
  44. json_data['paddingEnd'] || json_data['paddingHorizontal'] || json_data['paddingVertical']
  45. 7 required_imports&.add(:button_padding)
  46. 7 padding_values = []
  47. 7 if padding_data
  48. # Handle paddings array or padding number
  49. 5 if padding_data.is_a?(Array)
  50. 4 case padding_data.length
  51. when 1
  52. # One value: all sides
  53. 1 padding_values << "#{padding_data[0]}.dp"
  54. when 2
  55. # Two values: [vertical, horizontal]
  56. 1 padding_values << "vertical = #{padding_data[0]}.dp"
  57. 1 padding_values << "horizontal = #{padding_data[1]}.dp"
  58. when 3
  59. # Three values: [top, horizontal, bottom]
  60. 1 padding_values << "top = #{padding_data[0]}.dp"
  61. 1 padding_values << "horizontal = #{padding_data[1]}.dp"
  62. 1 padding_values << "bottom = #{padding_data[2]}.dp"
  63. when 4
  64. # Four values: [top, right, bottom, left]
  65. 1 padding_values << "top = #{padding_data[0]}.dp"
  66. 1 padding_values << "end = #{padding_data[1]}.dp"
  67. 1 padding_values << "bottom = #{padding_data[2]}.dp"
  68. 1 padding_values << "start = #{padding_data[3]}.dp"
  69. end
  70. else
  71. # Single number: all sides
  72. 1 padding_values << "#{padding_data}.dp"
  73. end
  74. else
  75. # Handle individual padding attributes
  76. 2 top_padding = json_data['paddingTop'] || json_data['paddingVertical'] || 0
  77. 2 bottom_padding = json_data['paddingBottom'] || json_data['paddingVertical'] || 0
  78. 2 start_padding = json_data['paddingStart'] || json_data['paddingLeft'] || json_data['paddingHorizontal'] || 0
  79. 2 end_padding = json_data['paddingEnd'] || json_data['paddingRight'] || json_data['paddingHorizontal'] || 0
  80. 2 if top_padding == bottom_padding && start_padding == end_padding && top_padding == start_padding
  81. # All same, use single value
  82. padding_values << "#{top_padding}.dp" if top_padding > 0
  83. 2 elsif top_padding == bottom_padding && start_padding == end_padding
  84. # Different horizontal and vertical
  85. 1 padding_values << "horizontal = #{start_padding}.dp" if start_padding > 0
  86. 1 padding_values << "vertical = #{top_padding}.dp" if top_padding > 0
  87. else
  88. # All different, need to specify each
  89. 1 padding_values << "start = #{start_padding}.dp" if start_padding > 0
  90. 1 padding_values << "top = #{top_padding}.dp" if top_padding > 0
  91. 1 padding_values << "end = #{end_padding}.dp" if end_padding > 0
  92. 1 padding_values << "bottom = #{bottom_padding}.dp" if bottom_padding > 0
  93. end
  94. end
  95. 7 if padding_values.any?
  96. 7 code += ",\n" + indent("contentPadding = PaddingValues(#{padding_values.join(', ')})", depth + 1)
  97. end
  98. end
  99. # Button colors including normal, disabled, and pressed states
  100. # Always set to match dynamic defaults
  101. 26 required_imports&.add(:button_colors)
  102. 26 colors_code = "colors = ButtonDefaults.buttonColors("
  103. 26 color_params = []
  104. 26 if json_data['background']
  105. 1 background_color = Helpers::ResourceResolver.process_color(json_data['background'], required_imports)
  106. 1 color_params << "containerColor = #{background_color}"
  107. else
  108. 25 color_params << "containerColor = Configuration.Button.defaultBackgroundColor"
  109. end
  110. 26 if json_data['disabledBackground']
  111. 1 disabled_bg_color = Helpers::ResourceResolver.process_color(json_data['disabledBackground'], required_imports)
  112. 1 color_params << "disabledContainerColor = #{disabled_bg_color}"
  113. end
  114. 26 if json_data['fontColor']
  115. 1 font_color = Helpers::ResourceResolver.process_color(json_data['fontColor'], required_imports)
  116. 1 color_params << "contentColor = #{font_color}"
  117. else
  118. 25 color_params << "contentColor = Configuration.Button.defaultTextColor"
  119. end
  120. 26 if json_data['disabledFontColor']
  121. 1 disabled_font_color = Helpers::ResourceResolver.process_color(json_data['disabledFontColor'], required_imports)
  122. 1 color_params << "disabledContentColor = #{disabled_font_color}"
  123. end
  124. # Note: hilightColor (pressed state) isn't directly supported in Material3 ButtonDefaults
  125. # We'd need a custom button implementation or InteractionSource for true pressed state
  126. 26 if json_data['hilightColor']
  127. 1 color_params << "// hilightColor: #{json_data['hilightColor']} - Use InteractionSource for pressed state"
  128. end
  129. 81 colors_code += "\n" + color_params.map { |param| indent(param, depth + 2) }.join(",\n")
  130. 26 colors_code += "\n" + indent(")", depth + 1)
  131. 26 code += ",\n" + indent(colors_code, depth + 1)
  132. # Handle border
  133. 26 if json_data['borderColor']
  134. required_imports&.add(:border_stroke)
  135. border_color = Helpers::ResourceResolver.process_color(json_data['borderColor'], required_imports)
  136. border_width = json_data['borderWidth'] || 1
  137. code += ",\n" + indent("border = BorderStroke(#{border_width}.dp, #{border_color})", depth + 1)
  138. end
  139. # Handle enabled attribute
  140. 26 if json_data.key?('enabled')
  141. 3 if json_data['enabled'].is_a?(String) && json_data['enabled'].start_with?('@{')
  142. # Data binding for enabled
  143. 1 variable = json_data['enabled'].match(/@\{([^}]+)\}/)[1]
  144. 1 code += ",\n" + indent("enabled = data.#{variable}", depth + 1)
  145. else
  146. 2 code += ",\n" + indent("enabled = #{json_data['enabled']}", depth + 1)
  147. end
  148. end
  149. 26 code += "\n" + indent(") {", depth)
  150. 26 code += "\n" + indent("Text(#{text})", depth + 1)
  151. # Apply text attributes if specified (fontColor handled in ButtonDefaults.buttonColors)
  152. 26 if json_data['fontSize']
  153. 1 text_code = "\n" + indent("Text(", depth + 1)
  154. 1 text_code += "\n" + indent("text = #{text},", depth + 2)
  155. 1 text_code += "\n" + indent("fontSize = #{json_data['fontSize']}.sp", depth + 2)
  156. 1 text_code += "\n" + indent(")", depth + 1)
  157. 1 code = code.sub(/Text\(#{Regexp.escape(text)}\)/, text_code.strip)
  158. end
  159. 26 code += "\n" + indent("}", depth)
  160. 26 code
  161. end
  162. 1 private
  163. 1 def self.indent(text, level)
  164. 280 return text if level == 0
  165. 201 spaces = ' ' * level
  166. 201 text.split("\n").map { |line|
  167. 282 line.empty? ? line : spaces + line
  168. }.join("\n")
  169. end
  170. end
  171. end
  172. end
  173. end

lib/compose/components/checkbox_component.rb

37.75% lines covered

151 relevant lines. 57 lines covered and 94 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. # CheckBox Component Generator
  8. # CheckBox is the primary component name. Check is supported as an alias for backward compatibility.
  9. # Both "CheckBox" and "Check" JSON types map to this component.
  10. 1 class CheckboxComponent
  11. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  12. # CheckBox uses 'isOn', 'checked', or 'bind' for binding
  13. # Priority: isOn > checked > bind
  14. 7 state_attr = json_data['isOn'] || json_data['checked']
  15. 7 checked = if state_attr
  16. 2 if state_attr.is_a?(String) && state_attr.match(/@\{([^}]+)\}/)
  17. 1 variable = $1
  18. 1 "data.#{variable}"
  19. else
  20. 1 state_attr.to_s
  21. end
  22. 5 elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  23. variable = $1
  24. "data.#{variable}"
  25. else
  26. 5 'false'
  27. end
  28. 7 has_label = json_data['label'] || json_data['text']
  29. 7 has_custom_icon = json_data['icon'] || json_data['selectedIcon']
  30. # If custom icons are specified, use IconToggleButton instead of Checkbox
  31. 7 if has_custom_icon
  32. return generate_icon_checkbox(json_data, depth, required_imports, parent_type, checked)
  33. end
  34. 7 if has_label
  35. # Checkbox with label
  36. code = indent("Row(", depth)
  37. code += "\n" + indent("verticalAlignment = Alignment.CenterVertically,", depth + 1)
  38. # Build modifiers for Row
  39. modifiers = []
  40. modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  41. modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  42. code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
  43. code += "\n" + indent(") {", depth)
  44. # Checkbox
  45. code += "\n" + indent("Checkbox(", depth + 1)
  46. code += "\n" + indent("checked = #{checked},", depth + 2)
  47. # onCheckedChange handler
  48. binding_variable = nil
  49. state_attr_val = json_data['isOn'] || json_data['checked']
  50. if state_attr_val.is_a?(String) && state_attr_val.match(/@\{([^}]+)\}/)
  51. binding_variable = $1
  52. elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  53. binding_variable = $1
  54. end
  55. if json_data['onValueChange']
  56. # onValueChange (camelCase) -> binding format only (@{functionName})
  57. if Helpers::ModifierBuilder.is_binding?(json_data['onValueChange'])
  58. method_name = Helpers::ModifierBuilder.extract_binding_property(json_data['onValueChange'])
  59. code += "\n" + indent("onCheckedChange = { viewModel.#{method_name}(it) }", depth + 2)
  60. else
  61. code += "\n" + indent("onCheckedChange = { // ERROR: #{json_data['onValueChange']} - camelCase events require binding format @{functionName} }", depth + 2)
  62. end
  63. elsif binding_variable
  64. code += "\n" + indent("onCheckedChange = { newValue -> viewModel.updateData(mapOf(\"#{binding_variable}\" to newValue)) }", depth + 2)
  65. else
  66. code += "\n" + indent("onCheckedChange = { }", depth + 2)
  67. end
  68. code += "\n" + indent(")", depth + 1)
  69. # Spacer with configurable spacing
  70. spacing = json_data['spacing'] || 8
  71. code += "\n" + indent("Spacer(modifier = Modifier.width(#{spacing}.dp))", depth + 1)
  72. # Label text with font attributes
  73. label_text = json_data['label'] || json_data['text']
  74. text_params = ["text = \"#{label_text}\""]
  75. if json_data['fontSize']
  76. text_params << "fontSize = #{json_data['fontSize']}.sp"
  77. end
  78. if json_data['fontColor']
  79. font_color = Helpers::ResourceResolver.process_color(json_data['fontColor'], required_imports)
  80. text_params << "color = #{font_color}"
  81. end
  82. if json_data['font']
  83. font_weight = json_data['font'].downcase == 'bold' ? 'FontWeight.Bold' : 'FontWeight.Normal'
  84. text_params << "fontWeight = #{font_weight}"
  85. end
  86. if text_params.size == 1
  87. code += "\n" + indent("Text(\"#{label_text}\")", depth + 1)
  88. else
  89. code += "\n" + indent("Text(", depth + 1)
  90. code += "\n" + text_params.map { |param| indent(param, depth + 2) }.join(",\n")
  91. code += "\n" + indent(")", depth + 1)
  92. end
  93. code += "\n" + indent("}", depth)
  94. else
  95. # Checkbox without label
  96. 7 code = indent("Checkbox(", depth)
  97. 7 code += "\n" + indent("checked = #{checked},", depth + 1)
  98. # onCheckedChange handler
  99. 7 binding_variable = nil
  100. 7 state_attr_val = json_data['isOn'] || json_data['checked']
  101. 7 if state_attr_val.is_a?(String) && state_attr_val.match(/@\{([^}]+)\}/)
  102. 1 binding_variable = $1
  103. 6 elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  104. binding_variable = $1
  105. end
  106. 7 if json_data['onValueChange']
  107. # onValueChange (camelCase) -> binding format only (@{functionName})
  108. if Helpers::ModifierBuilder.is_binding?(json_data['onValueChange'])
  109. method_name = Helpers::ModifierBuilder.extract_binding_property(json_data['onValueChange'])
  110. code += "\n" + indent("onCheckedChange = { viewModel.#{method_name}(it) },", depth + 1)
  111. else
  112. code += "\n" + indent("onCheckedChange = { // ERROR: #{json_data['onValueChange']} - camelCase events require binding format @{functionName} },", depth + 1)
  113. end
  114. 7 elsif binding_variable
  115. 1 code += "\n" + indent("onCheckedChange = { newValue -> viewModel.updateData(mapOf(\"#{binding_variable}\" to newValue)) },", depth + 1)
  116. else
  117. 6 code += "\n" + indent("onCheckedChange = { },", depth + 1)
  118. end
  119. # Build modifiers
  120. 7 modifiers = []
  121. 7 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  122. 7 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  123. 7 modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
  124. # Add weight modifier if in Row or Column
  125. 7 if parent_type == 'Row' || parent_type == 'Column'
  126. modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
  127. end
  128. 7 code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
  129. # Checkbox colors
  130. 7 if json_data['checkColor'] || json_data['uncheckedColor']
  131. 1 required_imports&.add(:checkbox_colors)
  132. 1 colors_params = []
  133. 1 if json_data['checkColor']
  134. 1 checked_color = Helpers::ResourceResolver.process_color(json_data['checkColor'], required_imports)
  135. 1 colors_params << "checkedColor = #{checked_color}"
  136. end
  137. 1 if json_data['uncheckedColor']
  138. unchecked_color = Helpers::ResourceResolver.process_color(json_data['uncheckedColor'], required_imports)
  139. colors_params << "uncheckedColor = #{unchecked_color}"
  140. end
  141. 1 if colors_params.any?
  142. 1 code += ",\n" + indent("colors = CheckboxDefaults.colors(", depth + 1)
  143. 2 code += "\n" + colors_params.map { |param| indent(param, depth + 2) }.join(",\n")
  144. 1 code += "\n" + indent(")", depth + 1)
  145. end
  146. end
  147. # Handle enabled attribute
  148. 7 if json_data.key?('enabled')
  149. if json_data['enabled'].is_a?(String) && json_data['enabled'].start_with?('@{')
  150. variable = json_data['enabled'].match(/@\{([^}]+)\}/)[1]
  151. code += ",\n" + indent("enabled = data.#{variable}", depth + 1)
  152. else
  153. code += ",\n" + indent("enabled = #{json_data['enabled']}", depth + 1)
  154. end
  155. end
  156. 7 code += "\n" + indent(")", depth)
  157. end
  158. 7 code
  159. end
  160. 1 private
  161. # Generate checkbox with custom icon/selectedIcon
  162. 1 def self.generate_icon_checkbox(json_data, depth, required_imports, parent_type, checked)
  163. required_imports&.add(:icon_toggle_button)
  164. required_imports&.add(:icon)
  165. icon = json_data['icon'] || 'check_box_outline_blank'
  166. selected_icon = json_data['selectedIcon'] || 'check_box'
  167. # Resolve icon names to drawable resources
  168. icon_res = Helpers::ResourceResolver.process_drawable(icon, required_imports)
  169. selected_icon_res = Helpers::ResourceResolver.process_drawable(selected_icon, required_imports)
  170. code = indent("IconToggleButton(", depth)
  171. code += "\n" + indent("checked = #{checked},", depth + 1)
  172. # onCheckedChange handler
  173. binding_variable = nil
  174. state_attr_val = json_data['isOn'] || json_data['checked']
  175. if state_attr_val.is_a?(String) && state_attr_val.match(/@\{([^}]+)\}/)
  176. binding_variable = $1
  177. elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  178. binding_variable = $1
  179. end
  180. if json_data['onValueChange']
  181. # onValueChange (camelCase) -> binding format only (@{functionName})
  182. if Helpers::ModifierBuilder.is_binding?(json_data['onValueChange'])
  183. method_name = Helpers::ModifierBuilder.extract_binding_property(json_data['onValueChange'])
  184. code += "\n" + indent("onCheckedChange = { viewModel.#{method_name}(it) }", depth + 1)
  185. else
  186. code += "\n" + indent("onCheckedChange = { // ERROR: #{json_data['onValueChange']} - camelCase events require binding format @{functionName} }", depth + 1)
  187. end
  188. elsif binding_variable
  189. code += "\n" + indent("onCheckedChange = { newValue -> viewModel.updateData(mapOf(\"#{binding_variable}\" to newValue)) }", depth + 1)
  190. else
  191. code += "\n" + indent("onCheckedChange = { }", depth + 1)
  192. end
  193. # Build modifiers
  194. modifiers = []
  195. modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  196. modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  197. modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
  198. code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
  199. code += "\n" + indent(") {", depth)
  200. # Icon content - switch based on checked state
  201. code += "\n" + indent("Icon(", depth + 1)
  202. code += "\n" + indent("painter = painterResource(if (#{checked}) #{selected_icon_res} else #{icon_res}),", depth + 2)
  203. code += "\n" + indent("contentDescription = null", depth + 2)
  204. # Icon tint color
  205. if json_data['fontColor']
  206. icon_color = Helpers::ResourceResolver.process_color(json_data['fontColor'], required_imports)
  207. code += ",\n" + indent("tint = #{icon_color}", depth + 2)
  208. end
  209. code += "\n" + indent(")", depth + 1)
  210. code += "\n" + indent("}", depth)
  211. code
  212. end
  213. 1 def self.indent(text, level)
  214. 31 return text if level == 0
  215. 17 spaces = ' ' * level
  216. 17 text.split("\n").map { |line|
  217. 17 line.empty? ? line : spaces + line
  218. }.join("\n")
  219. end
  220. end
  221. end
  222. end
  223. end

lib/compose/components/circleimage_component.rb

100.0% lines covered

55 relevant lines. 55 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class CircleImageComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. # CircleImage can be local or network image
  10. 21 is_network = json_data['url'] || (json_data['source'] && json_data['source'].start_with?('http'))
  11. 21 if is_network
  12. 4 required_imports&.add(:async_image)
  13. 4 url = process_data_binding(json_data['url'] || json_data['source'] || json_data['src'] || '')
  14. 4 code = indent("AsyncImage(", depth)
  15. 4 code += "\n" + indent("model = #{url},", depth + 1)
  16. else
  17. # Local image
  18. 17 image_name = json_data['source'] || json_data['src'] || 'placeholder'
  19. # Remove file extension and convert to resource name
  20. 17 resource_name = image_name.gsub('.png', '').gsub('.jpg', '').gsub('-', '_').downcase
  21. 17 code = indent("Image(", depth)
  22. 17 code += "\n" + indent("painter = painterResource(id = R.drawable.#{resource_name}),", depth + 1)
  23. end
  24. 21 content_description = json_data['contentDescription'] || 'Profile Image'
  25. 21 code += "\n" + indent("contentDescription = \"#{content_description}\",", depth + 1)
  26. # Content scale - typically Crop for circular images
  27. 21 required_imports&.add(:content_scale)
  28. 21 code += "\n" + indent("contentScale = ContentScale.Crop,", depth + 1)
  29. # Build modifiers for circular shape
  30. 21 modifiers = []
  31. # Size (use 'size' attribute or default to 48dp)
  32. 21 size = json_data['size'] || 48
  33. 21 modifiers << ".size(#{size}.dp)"
  34. # Circular clip
  35. 21 required_imports&.add(:shape)
  36. 21 modifiers << ".clip(CircleShape)"
  37. # Border for circle
  38. 21 if json_data['borderWidth'] && json_data['borderColor']
  39. 1 required_imports&.add(:border)
  40. 1 modifiers << ".border(#{json_data['borderWidth']}.dp, Helpers::ResourceResolver.process_color('#{json_data['borderColor']}', required_imports), CircleShape)"
  41. end
  42. # Background (in case image doesn't load)
  43. 21 if json_data['background']
  44. 1 required_imports&.add(:background)
  45. 1 modifiers << ".background(Helpers::ResourceResolver.process_color('#{json_data['background']}', required_imports))"
  46. end
  47. 21 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  48. 21 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  49. 21 modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
  50. 21 modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
  51. 21 code += Helpers::ModifierBuilder.format(modifiers, depth)
  52. # Error handling for network images
  53. 21 if is_network && json_data['errorImage']
  54. 1 code += ",\n" + indent("error = painterResource(R.drawable.#{json_data['errorImage'].gsub('.png', '').gsub('.jpg', '')})", depth + 1)
  55. end
  56. 21 code += "\n" + indent(")", depth)
  57. 21 code
  58. end
  59. 1 private
  60. 1 def self.process_data_binding(text)
  61. 7 return quote(text) unless text.is_a?(String)
  62. 7 if text.match(/@\{([^}]+)\}/)
  63. 2 variable = $1
  64. 2 "data.#{variable}"
  65. else
  66. 5 quote(text)
  67. end
  68. end
  69. 1 def self.quote(text)
  70. 7 "\"#{text.gsub('"', '\\"')}\""
  71. end
  72. 1 def self.indent(text, level)
  73. 108 return text if level == 0
  74. 65 spaces = ' ' * level
  75. 65 text.split("\n").map { |line|
  76. 65 line.empty? ? line : spaces + line
  77. }.join("\n")
  78. end
  79. end
  80. end
  81. end
  82. end

lib/compose/components/collection_component.rb

94.78% lines covered

230 relevant lines. 218 lines covered and 12 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 module KjuiTools
  4. 1 module Compose
  5. 1 module Components
  6. 1 class CollectionComponent
  7. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  8. 25 required_imports&.add(:lazy_grid)
  9. 25 required_imports&.add(:grid_item_span)
  10. # Check if sections are defined
  11. 25 sections = json_data['sections'] || []
  12. # Support both 'layout' and 'orientation' attributes for horizontal/vertical
  13. 25 layout = json_data['layout'] || json_data['orientation'] || 'vertical'
  14. 25 is_horizontal = layout == 'horizontal'
  15. # Legacy: Extract cellClasses, headerClasses, footerClasses (string arrays)
  16. 25 cell_classes = json_data['cellClasses'] || []
  17. 25 header_classes = json_data['headerClasses'] || []
  18. 25 footer_classes = json_data['footerClasses'] || []
  19. # Use the class names directly
  20. 25 cell_class_name = cell_classes.first if cell_classes.any?
  21. 25 header_class_name = header_classes.first if header_classes.any?
  22. 25 footer_class_name = footer_classes.first if footer_classes.any?
  23. # Calculate the grid columns based on sections or default
  24. 25 default_columns = json_data['columns'] || 1
  25. 25 if sections.any?
  26. # Collect all unique column counts from sections
  27. 26 section_columns = sections.map { |s| s['columns'] || default_columns }.uniq
  28. # If sections have different column counts, use LCM
  29. 12 if section_columns.size > 1
  30. 1 columns = calculate_lcm(section_columns)
  31. else
  32. 11 columns = section_columns.first
  33. end
  34. else
  35. 13 columns = default_columns
  36. end
  37. # Determine grid type based on layout
  38. 25 direction = is_horizontal ? 'horizontal' : 'vertical'
  39. 25 if direction == 'horizontal'
  40. 2 code = indent("LazyHorizontalGrid(", depth)
  41. 2 code += "\n" + indent("rows = GridCells.Fixed(#{columns}),", depth + 1)
  42. else
  43. 23 code = indent("LazyVerticalGrid(", depth)
  44. 23 code += "\n" + indent("columns = GridCells.Fixed(#{columns}),", depth + 1)
  45. end
  46. # Content padding
  47. # Support contentPadding (array or number), insetHorizontal, insetVertical
  48. 25 content_padding = json_data['contentPadding']
  49. 25 inset_horizontal = json_data['insetHorizontal']
  50. 25 inset_vertical = json_data['insetVertical']
  51. 25 if content_padding
  52. 2 if content_padding.is_a?(Array) && content_padding.length == 4
  53. 1 code += "\n" + indent("contentPadding = PaddingValues(top = #{content_padding[0]}.dp, end = #{content_padding[1]}.dp, bottom = #{content_padding[2]}.dp, start = #{content_padding[3]}.dp),", depth + 1)
  54. 1 elsif content_padding.is_a?(Numeric)
  55. 1 code += "\n" + indent("contentPadding = PaddingValues(#{content_padding}.dp),", depth + 1)
  56. end
  57. 23 elsif inset_horizontal || inset_vertical
  58. # Use insetHorizontal and/or insetVertical
  59. h_inset = inset_horizontal || 0
  60. v_inset = inset_vertical || 0
  61. code += "\n" + indent("contentPadding = PaddingValues(horizontal = #{h_inset}.dp, vertical = #{v_inset}.dp),", depth + 1)
  62. end
  63. # Item spacing
  64. # lineSpacing: vertical spacing between rows (minimumLineSpacing in iOS)
  65. # columnSpacing: horizontal spacing between columns (minimumInteritemSpacing in iOS)
  66. # itemSpacing/spacing: uniform spacing (fallback)
  67. 25 line_spacing = json_data['lineSpacing'] || json_data['itemSpacing'] || json_data['spacing']
  68. 25 column_spacing = json_data['columnSpacing'] || json_data['itemSpacing'] || json_data['spacing']
  69. 25 if line_spacing || column_spacing
  70. 2 required_imports&.add(:arrangement)
  71. 2 if line_spacing
  72. 2 code += "\n" + indent("verticalArrangement = Arrangement.spacedBy(#{line_spacing}.dp),", depth + 1)
  73. end
  74. 2 if column_spacing
  75. 2 code += "\n" + indent("horizontalArrangement = Arrangement.spacedBy(#{column_spacing}.dp),", depth + 1)
  76. end
  77. end
  78. # Parse gravity for item alignment (Box contentAlignment uses Alignment, not Alignment.Vertical/Horizontal)
  79. # Horizontal scroll: default is TopStart, can be Center/BottomStart
  80. # Vertical scroll: default is TopStart, can be TopCenter/TopEnd
  81. 25 gravity = json_data['gravity']
  82. 25 if is_horizontal
  83. # Horizontal scroll - vertical alignment
  84. 2 gravity_alignment = case gravity.to_s.downcase
  85. when 'center', 'centervertical'
  86. 'Alignment.CenterStart'
  87. when 'bottom'
  88. 'Alignment.BottomStart'
  89. else # 'top' is default for horizontal scroll
  90. 2 'Alignment.TopStart'
  91. end
  92. else
  93. # Vertical scroll - horizontal alignment
  94. 23 gravity_alignment = case gravity.to_s.downcase
  95. when 'center', 'centerhorizontal'
  96. 'Alignment.TopCenter'
  97. when 'right'
  98. 'Alignment.TopEnd'
  99. else # 'left' is default for vertical scroll
  100. 23 'Alignment.TopStart'
  101. end
  102. end
  103. # Build modifiers
  104. 25 modifiers = []
  105. # IMPORTANT: LazyVerticalGrid requires bounded width from parent
  106. # LazyHorizontalGrid requires bounded height from parent
  107. # If width/height is wrapContent, we MUST change it to avoid runtime crash
  108. 25 width_value = json_data['width']
  109. 25 height_value = json_data['height']
  110. 25 if !is_horizontal && width_value == 'wrapContent'
  111. # LazyVerticalGrid with wrapContent width causes crash
  112. # Use fillMaxWidth to take parent's width (works when parent has bounded width)
  113. modified_json = json_data.merge('width' => 'matchParent')
  114. modifiers.concat(Helpers::ModifierBuilder.build_size(modified_json))
  115. 25 elsif is_horizontal && height_value == 'wrapContent'
  116. # LazyHorizontalGrid with wrapContent height causes crash
  117. # Use fillMaxHeight to take parent's height
  118. modified_json = json_data.merge('height' => 'matchParent')
  119. modifiers.concat(Helpers::ModifierBuilder.build_size(modified_json))
  120. else
  121. 25 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  122. end
  123. 25 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  124. 25 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  125. 25 modifiers.concat(Helpers::ModifierBuilder.build_background(json_data, required_imports))
  126. 25 modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
  127. 25 code += Helpers::ModifierBuilder.format(modifiers, depth)
  128. 25 code += "\n" + indent(") {", depth)
  129. # Check if sections are defined
  130. 25 if sections.any?
  131. # Generate section-based collection
  132. 12 code += generate_sections_content(json_data, sections, columns, depth, required_imports, gravity_alignment)
  133. 13 elsif cell_class_name
  134. # Check if items property is specified (e.g., "@{items}")
  135. 5 items_property = json_data['items']
  136. 5 if items_property && items_property.match(/@\{([^}]+)\}/)
  137. # Extract property name from @{propertyName}
  138. 4 property_name = $1
  139. # Items should be a Map<String, List<Any>> where key is cell class name
  140. # Get the items for this specific cell class
  141. 4 code += "\n" + indent("// Collection with data source: #{property_name}[\"#{cell_class_name}\"]", depth + 1)
  142. 4 code += "\n" + indent("val cellItems = data.#{property_name}?.get(\"#{cell_class_name}\") ?: emptyList()", depth + 1)
  143. 4 code += "\n" + indent("items(cellItems.size) { index ->", depth + 1)
  144. 4 code += "\n" + indent("val item = cellItems[index]", depth + 2)
  145. else
  146. # Default to empty list
  147. 1 code += "\n" + indent("// Collection with no data source", depth + 1)
  148. 1 code += "\n" + indent("items(0) { index ->", depth + 1)
  149. 1 code += "\n" + indent("// No items", depth + 2)
  150. end
  151. # Create cell view with data
  152. 5 code += "\n" + indent("when (val itemData = item) {", depth + 2)
  153. 5 code += "\n" + indent("is #{cell_class_name}Data -> {", depth + 3)
  154. 5 code += "\n" + indent("#{cell_class_name}View(", depth + 4)
  155. 5 code += "\n" + indent("data = itemData,", depth + 5)
  156. 5 code += "\n" + indent("viewModel = viewModel(),", depth + 5)
  157. 5 code += "\n" + indent("modifier = Modifier", depth + 5)
  158. # Cell-specific modifiers
  159. 5 if json_data['cellHeight']
  160. 1 code += "\n" + indent(" .height(#{json_data['cellHeight']}.dp)", depth + 5)
  161. end
  162. # For grid layouts, ensure cells expand to fill width
  163. 5 if columns > 1
  164. 1 code += "\n" + indent(" .fillMaxWidth()", depth + 5)
  165. end
  166. 5 code += "\n" + indent(")", depth + 4)
  167. 5 code += "\n" + indent("}", depth + 3)
  168. 5 code += "\n" + indent("is Map<*, *> -> {", depth + 3)
  169. 5 code += "\n" + indent("// Convert map to data class", depth + 4)
  170. 5 code += "\n" + indent("val data = #{cell_class_name}Data.fromMap(itemData as Map<String, Any>)", depth + 4)
  171. 5 code += "\n" + indent("#{cell_class_name}View(", depth + 4)
  172. 5 code += "\n" + indent("data = data,", depth + 5)
  173. 5 code += "\n" + indent("viewModel = viewModel(),", depth + 5)
  174. 5 code += "\n" + indent("modifier = Modifier", depth + 5)
  175. # Cell-specific modifiers
  176. 5 if json_data['cellHeight']
  177. 1 code += "\n" + indent(" .height(#{json_data['cellHeight']}.dp)", depth + 5)
  178. end
  179. # For grid layouts, ensure cells expand to fill width
  180. 5 if columns > 1
  181. 1 code += "\n" + indent(" .fillMaxWidth()", depth + 5)
  182. end
  183. 5 code += "\n" + indent(")", depth + 4)
  184. 5 code += "\n" + indent("}", depth + 3)
  185. 5 code += "\n" + indent("else -> {", depth + 3)
  186. 5 code += "\n" + indent("// Unsupported item type", depth + 4)
  187. 5 code += "\n" + indent("}", depth + 3)
  188. 5 code += "\n" + indent("}", depth + 2)
  189. 5 code += "\n" + indent("}", depth + 1)
  190. else
  191. # No cell class specified - show placeholder
  192. 8 code += "\n" + indent("// No cellClasses specified", depth + 1)
  193. 8 code += "\n" + indent("items(10) { index ->", depth + 1)
  194. 8 code += "\n" + indent("Card(", depth + 2)
  195. 8 code += "\n" + indent("modifier = Modifier", depth + 3)
  196. 8 code += "\n" + indent(" .padding(4.dp)", depth + 3)
  197. 8 code += "\n" + indent(" .fillMaxWidth()", depth + 3)
  198. 8 code += "\n" + indent(" .height(80.dp)", depth + 3)
  199. 8 code += "\n" + indent(") {", depth + 2)
  200. 8 code += "\n" + indent("Box(", depth + 3)
  201. 8 code += "\n" + indent("modifier = Modifier.fillMaxSize(),", depth + 4)
  202. 8 code += "\n" + indent("contentAlignment = Alignment.Center", depth + 4)
  203. 8 code += "\n" + indent(") {", depth + 3)
  204. 8 code += "\n" + indent("Text(\"Item ${index}\")", depth + 4)
  205. 8 code += "\n" + indent("}", depth + 3)
  206. 8 code += "\n" + indent("}", depth + 2)
  207. 8 code += "\n" + indent("}", depth + 1)
  208. end
  209. 25 code += "\n" + indent("}", depth)
  210. 25 code
  211. end
  212. 1 def self.generate_sections_content(json_data, sections, grid_columns, depth, required_imports, gravity_alignment)
  213. 12 code = ""
  214. 12 items_property = json_data['items']
  215. 12 default_columns = json_data['columns'] || 1
  216. # Check if we need GridItemSpan
  217. # Need it for headers/footers or when sections have different column counts
  218. 26 has_headers_or_footers = sections.any? { |s| s['header'] || s['footer'] }
  219. 26 section_columns_vary = sections.map { |s| s['columns'] || default_columns }.uniq.size > 1
  220. 25 needs_span = sections.any? { |s| s['columns'] && s['columns'] != grid_columns }
  221. 12 if has_headers_or_footers || section_columns_vary || needs_span
  222. 3 required_imports&.add(:grid_item_span)
  223. end
  224. # Always add cell imports for all sections (regardless of items binding)
  225. 12 sections.each do |section|
  226. 14 cell_view_name = section['cell']
  227. 14 if cell_view_name
  228. 9 required_imports&.add("cell:#{cell_view_name}")
  229. end
  230. 14 if section['header']
  231. 1 required_imports&.add("cell:#{section['header']}")
  232. end
  233. 14 if section['footer']
  234. 1 required_imports&.add("cell:#{section['footer']}")
  235. end
  236. end
  237. 12 if items_property && items_property.match(/@\{([^}]+)\}/)
  238. 7 property_name = $1
  239. # Generate sections with GridItemSpan for different column counts
  240. 7 sections.each_with_index do |section, index|
  241. 7 cell_view_name = section['cell']
  242. 7 section_columns = section['columns'] || default_columns
  243. # Calculate the span for items in this section
  244. 7 item_span = grid_columns / section_columns
  245. 7 if cell_view_name
  246. 7 code += "\n" + indent("// Section #{index + 1}: #{cell_view_name} (#{section_columns} columns)", depth + 1)
  247. 7 code += "\n" + indent("data.#{property_name}?.sections?.getOrNull(#{index})?.let { section ->", depth + 1)
  248. # Generate header if present
  249. 7 if section['header']
  250. 1 header_view_name = section['header']
  251. 1 code += "\n" + indent("// Section #{index + 1} Header: #{header_view_name}", depth + 2)
  252. 1 code += "\n" + indent("section.header?.let { headerData ->", depth + 2)
  253. 1 code += "\n" + indent("item(span = { GridItemSpan(maxLineSpan) }) {", depth + 3)
  254. 1 code += "\n" + indent("val headerViewModel: #{header_view_name}ViewModel = viewModel(key = \"#{header_view_name}_header_#{index}\")", depth + 4)
  255. 1 code += "\n" + indent("headerViewModel.updateData(headerData.data)", depth + 4)
  256. 1 code += "\n" + indent("#{header_view_name}View(", depth + 4)
  257. 1 code += "\n" + indent("viewModel = headerViewModel,", depth + 5)
  258. 1 code += "\n" + indent("modifier = Modifier.fillMaxWidth()", depth + 5)
  259. 1 code += "\n" + indent(")", depth + 4)
  260. 1 code += "\n" + indent("}", depth + 3)
  261. 1 code += "\n" + indent("}", depth + 2)
  262. end
  263. # Generate cells
  264. 7 code += "\n" + indent("section.cells?.let { cellData ->", depth + 2)
  265. 7 if item_span > 1
  266. code += "\n" + indent("items(cellData.data.size, span = { GridItemSpan(#{item_span}) }) { cellIndex ->", depth + 3)
  267. else
  268. 7 code += "\n" + indent("items(cellData.data.size) { cellIndex ->", depth + 3)
  269. end
  270. # Wrap cell in Box for alignment
  271. 7 code += "\n" + indent("Box(", depth + 4)
  272. 7 code += "\n" + indent("modifier = Modifier.fillMaxSize(),", depth + 5)
  273. 7 code += "\n" + indent("contentAlignment = #{gravity_alignment}", depth + 5)
  274. 7 code += "\n" + indent(") {", depth + 4)
  275. 7 code += "\n" + indent("val cellViewModel: #{cell_view_name}ViewModel = viewModel(key = \"#{cell_view_name}_cell_\$cellIndex\")", depth + 5)
  276. 7 code += "\n" + indent("cellViewModel.updateData(cellData.data[cellIndex])", depth + 5)
  277. 7 code += "\n" + indent("#{cell_view_name}View(", depth + 5)
  278. 7 code += "\n" + indent("viewModel = cellViewModel,", depth + 6)
  279. 7 code += "\n" + indent("modifier = Modifier", depth + 6)
  280. 7 code += "\n" + indent(")", depth + 5)
  281. 7 code += "\n" + indent("}", depth + 4)
  282. 7 code += "\n" + indent("}", depth + 3)
  283. 7 code += "\n" + indent("}", depth + 2)
  284. # Generate footer if present
  285. 7 if section['footer']
  286. 1 footer_view_name = section['footer']
  287. 1 code += "\n" + indent("// Section #{index + 1} Footer: #{footer_view_name}", depth + 2)
  288. 1 code += "\n" + indent("section.footer?.let { footerData ->", depth + 2)
  289. 1 code += "\n" + indent("item(span = { GridItemSpan(maxLineSpan) }) {", depth + 3)
  290. 1 code += "\n" + indent("val footerViewModel: #{footer_view_name}ViewModel = viewModel(key = \"#{footer_view_name}_footer_#{index}\")", depth + 4)
  291. 1 code += "\n" + indent("footerViewModel.updateData(footerData.data)", depth + 4)
  292. 1 code += "\n" + indent("#{footer_view_name}View(", depth + 4)
  293. 1 code += "\n" + indent("viewModel = footerViewModel,", depth + 5)
  294. 1 code += "\n" + indent("modifier = Modifier.fillMaxWidth()", depth + 5)
  295. 1 code += "\n" + indent(")", depth + 4)
  296. 1 code += "\n" + indent("}", depth + 3)
  297. 1 code += "\n" + indent("}", depth + 2)
  298. end
  299. 7 code += "\n" + indent("}", depth + 1)
  300. end
  301. end
  302. else
  303. 5 code += "\n" + indent("// No items binding specified", depth + 1)
  304. end
  305. 12 code
  306. end
  307. 1 private
  308. 1 def self.calculate_lcm(numbers)
  309. 11 numbers.reduce(1) { |lcm, n| lcm.lcm(n) }
  310. end
  311. 1 def self.extract_view_name(class_name)
  312. 4 return nil unless class_name
  313. # Convert cell class name to Compose view name
  314. # Remove common suffixes and add appropriate naming
  315. 3 view_name = class_name
  316. # Remove common UIKit/Android suffixes
  317. 3 view_name = view_name.sub(/CollectionViewCell$/, '')
  318. 3 view_name = view_name.sub(/Cell$/, '')
  319. 3 view_name = view_name.sub(/cell$/, '')
  320. # Convert to proper case and add View suffix if needed
  321. 3 view_name = to_pascal_case(view_name)
  322. 3 view_name += 'View' unless view_name.end_with?('View')
  323. 3 view_name
  324. end
  325. 1 def self.to_pascal_case(str)
  326. 7 return str if str.nil? || str.empty?
  327. # Handle snake_case or kebab-case to PascalCase
  328. 5 parts = str.split(/[_-]/)
  329. 5 parts.map(&:capitalize).join
  330. end
  331. 1 def self.indent(text, level)
  332. 523 return text if level == 0
  333. 447 spaces = ' ' * level
  334. 447 text.split("\n").map { |line|
  335. 449 line.empty? ? line : spaces + line
  336. }.join("\n")
  337. end
  338. end
  339. end
  340. end
  341. end

lib/compose/components/constraintlayout_component.rb

91.1% lines covered

146 relevant lines. 133 lines covered and 13 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class ConstraintLayoutComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. 6 required_imports&.add(:constraint_layout)
  10. # Check if any child has relative positioning attributes
  11. 6 children = json_data['child'] || []
  12. 6 children = [children] unless children.is_a?(Array)
  13. 11 has_constraints = children.any? { |child| has_relative_positioning?(child) }
  14. 6 if has_constraints
  15. 4 generate_constraint_layout(json_data, children, depth, required_imports, parent_type)
  16. else
  17. # Fall back to regular Box/Column/Row
  18. 2 Components::ContainerComponent.generate(json_data, depth, required_imports)
  19. end
  20. end
  21. 1 private
  22. 1 def self.has_relative_positioning?(component)
  23. 19 return false unless component.is_a?(Hash)
  24. 17 relative_attrs = [
  25. 'alignTopOfView', 'alignBottomOfView', 'alignLeftOfView', 'alignRightOfView',
  26. 'alignTopView', 'alignBottomView', 'alignLeftView', 'alignRightView',
  27. 'alignCenterVerticalView', 'alignCenterHorizontalView',
  28. 'alignTop', 'alignBottom', 'alignLeft', 'alignRight',
  29. 'centerHorizontal', 'centerVertical', 'centerInParent'
  30. ]
  31. 239 relative_attrs.any? { |attr| component[attr] }
  32. end
  33. 1 def self.has_positioning_constraints?(component)
  34. 19 return false unless component.is_a?(Hash)
  35. # These are constraints that use margins in linkTo()
  36. # For alignXxxView, margins should be applied as padding modifiers
  37. # For alignTop/Bottom/Left/Right to parent, margins are applied in linkTo()
  38. 18 positioning_attrs = [
  39. 'alignTopOfView', 'alignBottomOfView', 'alignLeftOfView', 'alignRightOfView',
  40. 'alignTopView', 'alignBottomView', 'alignLeftView', 'alignRightView',
  41. 'alignCenterVerticalView', 'alignCenterHorizontalView',
  42. 'alignTop', 'alignBottom', 'alignLeft', 'alignRight'
  43. ]
  44. # centerInParent, centerHorizontal, centerVertical don't use margins in linkTo()
  45. # so they should still apply margins as padding
  46. 245 positioning_attrs.any? { |attr| component[attr] }
  47. end
  48. 1 def self.should_apply_margins_as_padding?(component)
  49. 16 return false unless component.is_a?(Hash)
  50. # Don't apply margins as padding if they're already handled in linkTo()
  51. # All positioning constraints now handle margins in linkTo()
  52. 15 return !has_positioning_constraints?(component)
  53. end
  54. 1 def self.generate_constraint_layout(json_data, children, depth, required_imports, parent_type = nil)
  55. 4 code = indent("ConstraintLayout(", depth)
  56. # Build modifiers
  57. 4 modifiers = []
  58. 4 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  59. 4 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  60. 4 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  61. 4 modifiers.concat(Helpers::ModifierBuilder.build_background(json_data, required_imports))
  62. 4 modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
  63. 4 code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
  64. 4 code += "\n" + indent(") {", depth)
  65. # Create constraint references
  66. 4 constraint_refs = []
  67. 4 children.each_with_index do |child, index|
  68. 4 if child.is_a?(Hash) && (child['id'] || has_relative_positioning?(child))
  69. 4 ref_name = child['id'] || "view_#{index}"
  70. 4 code += "\n" + indent("val #{ref_name} = createRef()", depth + 1)
  71. 4 constraint_refs << ref_name
  72. end
  73. end
  74. 4 code += "\n" if constraint_refs.any?
  75. # Generate children with constraints
  76. 4 children.each_with_index do |child, index|
  77. 4 if child.is_a?(Hash)
  78. 4 ref_name = child['id'] || "view_#{index}"
  79. # Generate the child component
  80. 4 child_code = generate_child_with_constraints(child, ref_name, depth + 1, required_imports)
  81. 4 code += "\n" + child_code unless child_code.empty?
  82. end
  83. end
  84. 4 code += "\n" + indent("}", depth)
  85. 4 code
  86. end
  87. 1 def self.generate_child_with_constraints(child_data, ref_name, depth, required_imports)
  88. # Get the component type
  89. 4 component_type = child_data['type'] || 'View'
  90. # Generate the component code based on type
  91. 4 component_code = case component_type
  92. when 'Text', 'Label'
  93. 3 generate_text_component(child_data, depth, required_imports)
  94. when 'Button'
  95. 1 generate_button_component(child_data, depth, required_imports)
  96. when 'Image'
  97. generate_image_component(child_data, depth, required_imports)
  98. else
  99. generate_box_component(child_data, depth, required_imports)
  100. end
  101. # Add constrainAs modifier
  102. 4 constraints = Helpers::ModifierBuilder.build_relative_positioning(child_data)
  103. # Always add constrainAs for all children in ConstraintLayout
  104. # Insert constrainAs modifier
  105. 4 constraint_content = if constraints.any?
  106. 12 constraints.map { |c| indent(c, depth + 2) }.join("\n")
  107. else
  108. "" # Empty constraint block
  109. end
  110. # Find where to insert the constrainAs modifier
  111. 4 if component_code.include?("modifier = Modifier")
  112. # Replace existing modifier with constrainAs
  113. component_code.sub!(/modifier = Modifier(.*?)(?=,\n|\n)/m) do |match|
  114. existing_modifiers = $1
  115. "modifier = Modifier.constrainAs(#{ref_name}) {\n#{constraint_content}\n" + indent("}", depth + 1) + existing_modifiers
  116. end
  117. else
  118. # Add new modifier after the opening parenthesis
  119. 4 insert_pos = component_code.index("(") + 1
  120. 4 modifier_code = "\n" + indent("modifier = Modifier.constrainAs(#{ref_name}) {", depth + 1)
  121. 4 if constraint_content.length > 0
  122. 4 modifier_code += "\n#{constraint_content}"
  123. end
  124. 4 modifier_code += "\n" + indent("}", depth + 1) + ","
  125. 4 component_code.insert(insert_pos, modifier_code)
  126. end
  127. 4 component_code
  128. end
  129. 1 def self.generate_text_component(data, depth, required_imports)
  130. 13 text = data['text'] || ''
  131. # Check for data binding
  132. 13 if text.start_with?('@{')
  133. 1 variable_name = text[2..-2]
  134. 1 escaped_text = "\"${data.#{variable_name}}\""
  135. else
  136. 12 escaped_text = quote(text)
  137. end
  138. 13 code = indent("Text(", depth)
  139. # Add modifier with constraints
  140. # In ConstraintLayout:
  141. # - If element has relative positioning constraints, margins are handled ONLY in linkTo()
  142. # - If element has no constraints (just centerInParent etc), margins are applied as padding
  143. 13 modifiers = []
  144. # Apply margins BEFORE size so they act as outer spacing
  145. # This ensures the size is the actual content size, not reduced by margins
  146. 13 if should_apply_margins_as_padding?(data)
  147. 11 modifiers.concat(Helpers::ModifierBuilder.build_margins(data))
  148. end
  149. 13 modifiers.concat(Helpers::ModifierBuilder.build_size(data))
  150. 13 modifiers.concat(Helpers::ModifierBuilder.build_background(data, required_imports))
  151. 13 modifiers.concat(Helpers::ModifierBuilder.build_padding(data))
  152. 13 if modifiers.any?
  153. code += "\n" + indent("modifier = Modifier", depth + 1)
  154. modifiers.each do |mod|
  155. code += "\n" + indent(" #{mod}", depth + 1)
  156. end
  157. code += ","
  158. end
  159. 13 code += "\n" + indent("text = #{escaped_text}", depth + 1)
  160. 13 if data['fontSize']
  161. 1 code += ",\n" + indent("fontSize = #{data['fontSize']}.sp", depth + 1)
  162. end
  163. 13 if data['fontColor'] || data['color']
  164. 2 color = data['fontColor'] || data['color']
  165. 2 color_resolved = Helpers::ResourceResolver.process_color(color, required_imports)
  166. 2 code += ",\n" + indent("color = #{color_resolved}", depth + 1)
  167. end
  168. 13 if data['font'] == 'bold' || data['fontWeight'] == 'bold'
  169. 2 required_imports&.add(:font_weight)
  170. 2 code += ",\n" + indent("fontWeight = FontWeight.Bold", depth + 1)
  171. end
  172. 13 if data['textAlign']
  173. 3 required_imports&.add(:text_align)
  174. 3 align = case data['textAlign']
  175. 1 when 'center' then 'TextAlign.Center'
  176. 1 when 'left' then 'TextAlign.Left'
  177. 1 when 'right' then 'TextAlign.Right'
  178. else 'TextAlign.Start'
  179. end
  180. 3 code += ",\n" + indent("textAlign = #{align}", depth + 1)
  181. end
  182. 13 code += "\n" + indent(")", depth)
  183. 13 code
  184. end
  185. 1 def self.generate_button_component(data, depth, required_imports)
  186. 5 text = data['text'] || 'Button'
  187. # Properly escape text
  188. 5 escaped_text = quote(text)
  189. 5 code = indent("Button(", depth)
  190. # onclick (lowercase) -> selector format only
  191. # onClick (camelCase) -> binding format only
  192. 5 if data['onclick']
  193. 1 handler_call = Helpers::ModifierBuilder.get_event_handler_call(data['onclick'], is_camel_case: false)
  194. 1 code += "\n" + indent("onClick = { #{handler_call} }", depth + 1)
  195. 4 elsif data['onClick']
  196. handler_call = Helpers::ModifierBuilder.get_event_handler_call(data['onClick'], is_camel_case: true)
  197. code += "\n" + indent("onClick = { #{handler_call} }", depth + 1)
  198. else
  199. 4 code += "\n" + indent("onClick = { }", depth + 1)
  200. end
  201. 5 code += "\n" + indent(") {", depth)
  202. 5 code += "\n" + indent("Text(#{escaped_text})", depth + 1)
  203. 5 code += "\n" + indent("}", depth)
  204. 5 code
  205. end
  206. 1 def self.generate_image_component(data, depth, required_imports)
  207. 4 source = data['src'] || data['source'] || 'placeholder'
  208. 4 code = indent("Image(", depth)
  209. 4 code += "\n" + indent("painter = painterResource(R.drawable.#{source.gsub('.png', '').gsub('.jpg', '')}),", depth + 1)
  210. 4 code += "\n" + indent("contentDescription = \"Image\"", depth + 1)
  211. 4 code += "\n" + indent(")", depth)
  212. 4 code
  213. end
  214. 1 def self.generate_box_component(data, depth, required_imports)
  215. 1 code = indent("Box(", depth)
  216. 1 code += "\n" + indent(") {", depth)
  217. 1 code += "\n" + indent("// Content", depth + 1)
  218. 1 code += "\n" + indent("}", depth)
  219. 1 code
  220. end
  221. 1 def self.quote(text)
  222. # Escape special characters properly
  223. 20 escaped = text.gsub('\\', '\\\\\\\\') # Escape backslashes first
  224. .gsub('"', '\\"') # Escape quotes
  225. .gsub("\n", '\\n') # Escape newlines
  226. .gsub("\r", '\\r') # Escape carriage returns
  227. .gsub("\t", '\\t') # Escape tabs
  228. 20 "\"#{escaped}\""
  229. end
  230. 1 def self.indent(text, level)
  231. 127 return text if level == 0
  232. 71 spaces = ' ' * level
  233. 71 text.split("\n").map { |line|
  234. 73 line.empty? ? line : spaces + line
  235. }.join("\n")
  236. end
  237. end
  238. end
  239. end
  240. end

lib/compose/components/container_component.rb

84.62% lines covered

91 relevant lines. 77 lines covered and 14 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative 'constraintlayout_component'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class ContainerComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. 22 container_type = json_data['type'] || 'View'
  10. 22 orientation = json_data['orientation']
  11. # Check if any child has relative positioning
  12. 22 children = json_data['child'] || []
  13. 22 children = [children] unless children.is_a?(Array)
  14. 22 if has_relative_positioning?(children)
  15. # Use ConstraintLayout for relative positioning
  16. return ConstraintLayoutComponent.generate(json_data, depth, required_imports)
  17. end
  18. # Determine layout type
  19. 22 layout = determine_layout(container_type, orientation)
  20. 22 code = indent("#{layout}(", depth)
  21. # Build modifiers (correct order for Compose)
  22. 22 modifiers = []
  23. # Add weight modifier if in Row or Column
  24. 22 if parent_type == 'Row' || parent_type == 'Column'
  25. modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
  26. end
  27. # 1. Size first (total size including padding)
  28. 22 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  29. # 2. Margins (outer spacing)
  30. 22 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  31. # 3. Background (before padding so padding creates space inside)
  32. 22 modifiers.concat(Helpers::ModifierBuilder.build_background(json_data, required_imports))
  33. # 4. Padding (inner spacing) - applied last
  34. 22 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  35. 22 code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
  36. # Add gravity settings
  37. 22 if json_data['gravity']
  38. 6 code += add_gravity_settings(layout, json_data['gravity'], depth)
  39. end
  40. # Add direction settings
  41. # Note: reverseLayout is only supported by LazyColumn/LazyRow, not Column/Row
  42. # For regular Row/Column, we need to manually reverse the children order
  43. 22 if json_data['direction'] && (layout == 'Column' || layout == 'Row')
  44. # Direction handling will be done by reversing children order
  45. # No reverseLayout parameter for regular Row/Column
  46. end
  47. # Add spacing for Column/Row
  48. 22 if json_data['spacing'] && (layout == 'Column' || layout == 'Row')
  49. 2 required_imports&.add(:arrangement)
  50. 2 code += ",\n" + indent("verticalArrangement = Arrangement.spacedBy(#{json_data['spacing']}.dp)", depth + 1) if layout == 'Column'
  51. 2 code += ",\n" + indent("horizontalArrangement = Arrangement.spacedBy(#{json_data['spacing']}.dp)", depth + 1) if layout == 'Row'
  52. end
  53. # Add distribution for Column/Row
  54. 22 if json_data['distribution'] && (layout == 'Column' || layout == 'Row')
  55. 3 required_imports&.add(:arrangement)
  56. 3 arrangement = case json_data['distribution']
  57. when 'fillEqually'
  58. 1 'Arrangement.SpaceEvenly'
  59. when 'fill'
  60. 1 'Arrangement.SpaceBetween'
  61. when 'equalSpacing'
  62. 1 'Arrangement.SpaceAround'
  63. when 'equalCentering'
  64. 'Arrangement.SpaceEvenly'
  65. else
  66. nil
  67. end
  68. 3 if arrangement
  69. 3 code += ",\n" + indent("verticalArrangement = #{arrangement}", depth + 1) if layout == 'Column'
  70. 3 code += ",\n" + indent("horizontalArrangement = #{arrangement}", depth + 1) if layout == 'Row'
  71. end
  72. end
  73. 22 code += "\n" + indent(") {", depth)
  74. # Process children
  75. 22 children = json_data['child'] || []
  76. 22 children = [children] unless children.is_a?(Array)
  77. # Reverse children order if direction requires it
  78. 22 if json_data['direction']
  79. 2 case json_data['direction']
  80. when 'bottomToTop'
  81. 1 children = children.reverse if layout == 'Column'
  82. when 'rightToLeft'
  83. 1 children = children.reverse if layout == 'Row'
  84. end
  85. end
  86. # Return structure for parent to process children
  87. 22 { code: code, children: children, closing: "\n" + indent("}", depth), layout_type: layout, json_data: json_data }
  88. end
  89. 1 private
  90. 1 def self.has_relative_positioning?(children)
  91. 25 relative_attrs = [
  92. 'alignTopOfView', 'alignBottomOfView', 'alignLeftOfView', 'alignRightOfView',
  93. 'alignTopView', 'alignBottomView', 'alignLeftView', 'alignRightView',
  94. 'alignCenterVerticalView', 'alignCenterHorizontalView'
  95. ]
  96. 25 children.any? do |child|
  97. 12 next false unless child.is_a?(Hash)
  98. 101 relative_attrs.any? { |attr| child[attr] }
  99. end
  100. end
  101. 1 def self.determine_layout(container_type, orientation)
  102. # SwiftJsonUI only has 'View' type, not VStack/HStack/ZStack
  103. # Layout is determined by orientation attribute:
  104. # - orientation: "vertical" → Column (VStack)
  105. # - orientation: "horizontal" → Row (HStack)
  106. # - no orientation → Box (ZStack)
  107. 26 if container_type == 'View'
  108. 23 if orientation == 'vertical'
  109. 12 'Column'
  110. 11 elsif orientation == 'horizontal'
  111. 7 'Row'
  112. else
  113. 4 'Box'
  114. end
  115. else
  116. # For other types (shouldn't happen with proper View type)
  117. 3 'Box'
  118. end
  119. end
  120. 1 def self.add_gravity_settings(layout, gravity, depth)
  121. 6 code = ""
  122. 6 if layout == 'Column'
  123. 4 case gravity
  124. when 'top'
  125. 1 code += ",\n" + indent("verticalArrangement = Arrangement.Top", depth + 1)
  126. when 'bottom'
  127. 1 code += ",\n" + indent("verticalArrangement = Arrangement.Bottom", depth + 1)
  128. when 'centerVertical'
  129. 1 code += ",\n" + indent("verticalArrangement = Arrangement.Center", depth + 1)
  130. when 'left'
  131. code += ",\n" + indent("horizontalAlignment = Alignment.Start", depth + 1)
  132. when 'right'
  133. code += ",\n" + indent("horizontalAlignment = Alignment.End", depth + 1)
  134. when 'centerHorizontal'
  135. 1 code += ",\n" + indent("horizontalAlignment = Alignment.CenterHorizontally", depth + 1)
  136. when 'center'
  137. # center applies both vertical arrangement and horizontal alignment
  138. code += ",\n" + indent("verticalArrangement = Arrangement.Center", depth + 1)
  139. code += ",\n" + indent("horizontalAlignment = Alignment.CenterHorizontally", depth + 1)
  140. end
  141. 2 elsif layout == 'Row'
  142. 2 case gravity
  143. when 'left'
  144. 1 code += ",\n" + indent("horizontalArrangement = Arrangement.Start", depth + 1)
  145. when 'right'
  146. code += ",\n" + indent("horizontalArrangement = Arrangement.End", depth + 1)
  147. when 'centerHorizontal'
  148. code += ",\n" + indent("horizontalArrangement = Arrangement.Center", depth + 1)
  149. when 'top'
  150. code += ",\n" + indent("verticalAlignment = Alignment.Top", depth + 1)
  151. when 'bottom'
  152. code += ",\n" + indent("verticalAlignment = Alignment.Bottom", depth + 1)
  153. when 'centerVertical'
  154. 1 code += ",\n" + indent("verticalAlignment = Alignment.CenterVertically", depth + 1)
  155. when 'center'
  156. # center applies both horizontal arrangement and vertical alignment
  157. code += ",\n" + indent("horizontalArrangement = Arrangement.Center", depth + 1)
  158. code += ",\n" + indent("verticalAlignment = Alignment.CenterVertically", depth + 1)
  159. end
  160. end
  161. 6 code
  162. end
  163. 1 def self.indent(text, level)
  164. 77 return text if level == 0
  165. 11 spaces = ' ' * level
  166. 11 text.split("\n").map { |line|
  167. 11 line.empty? ? line : spaces + line
  168. }.join("\n")
  169. end
  170. end
  171. end
  172. end
  173. end

lib/compose/components/gradientview_component.rb

73.33% lines covered

45 relevant lines. 33 lines covered and 12 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class GradientviewComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. # GradientView maps to a Box with gradient background
  10. 4 code = indent("Box(", depth)
  11. # Build modifiers
  12. 4 modifiers = []
  13. 4 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  14. 4 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  15. 4 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  16. # Add gradient background
  17. # Support both 'colors' and 'items' for color list
  18. 4 colors = json_data['colors'] || json_data['items'] || ['#000000', '#FFFFFF']
  19. # Determine gradient direction from orientation or start/end points
  20. 4 gradient_type = if json_data['orientation']
  21. case json_data['orientation']
  22. when 'horizontal'
  23. 'horizontalGradient'
  24. when 'vertical'
  25. 'verticalGradient'
  26. when 'diagonal'
  27. 'linearGradient'
  28. else
  29. 'verticalGradient'
  30. end
  31. else
  32. 4 start_point = json_data['startPoint'] || 'top'
  33. 4 end_point = json_data['endPoint'] || 'bottom'
  34. 4 case [start_point, end_point]
  35. when ['top', 'bottom'], ['bottom', 'top']
  36. 4 'verticalGradient'
  37. when ['left', 'right'], ['leading', 'trailing'], ['right', 'left'], ['trailing', 'leading']
  38. 'horizontalGradient'
  39. else
  40. 'linearGradient'
  41. end
  42. end
  43. # Build color list - process colors at generation time, not runtime
  44. 4 color_list = colors.map { |color|
  45. 8 Helpers::ResourceResolver.process_color(color, required_imports)
  46. }.join(", ")
  47. # Add gradient modifier
  48. 4 required_imports&.add(:gradient)
  49. 4 modifiers << ".background(Brush.#{gradient_type}(listOf(#{color_list})))"
  50. # Add corner radius if specified
  51. 4 if json_data['cornerRadius']
  52. required_imports&.add(:shape)
  53. modifiers << ".clip(RoundedCornerShape(#{json_data['cornerRadius']}.dp))"
  54. end
  55. 4 modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
  56. 4 modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
  57. 4 code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
  58. 4 code += "\n" + indent(") {", depth)
  59. # Process children
  60. 4 children = json_data['child'] || []
  61. 4 children = [children] unless children.is_a?(Array)
  62. # Return structure for parent to process children
  63. 4 { code: code, children: children, closing: "\n" + indent("}", depth), json_data: json_data }
  64. end
  65. 1 private
  66. 1 def self.indent(text, level)
  67. 12 return text if level == 0
  68. spaces = ' ' * level
  69. text.split("\n").map { |line|
  70. line.empty? ? line : spaces + line
  71. }.join("\n")
  72. end
  73. end
  74. end
  75. end
  76. end

lib/compose/components/image_component.rb

84.31% lines covered

51 relevant lines. 43 lines covered and 8 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 module KjuiTools
  4. 1 module Compose
  5. 1 module Components
  6. 1 class ImageComponent
  7. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  8. # 'src' is the official attribute for images per wiki
  9. 10 raw_src = json_data['src'] || 'placeholder'
  10. # Add required imports
  11. 10 required_imports&.add(:image)
  12. 10 code = indent("Image(", depth)
  13. # Check if src is a binding expression
  14. 10 if Helpers::ModifierBuilder.is_binding?(raw_src)
  15. # @{mapTabIcon} -> data.mapTabIcon (expects Painter type in Data)
  16. property_name = Helpers::ModifierBuilder.extract_binding_property(raw_src)
  17. camel_case_name = to_camel_case(property_name)
  18. # Binding case doesn't need painterResource import since Data provides Painter directly
  19. # Painter is optional, so use inline empty painter as default
  20. required_imports&.add(:painter_class)
  21. code += "\n" + indent("painter = data.#{camel_case_name} ?: object : Painter() { override val intrinsicSize get() = Size.Unspecified; override fun DrawScope.onDraw() {} },", depth + 1)
  22. else
  23. # Static resource name needs painterResource
  24. 10 required_imports&.add(:painter_resource)
  25. 10 required_imports&.add(:r_class)
  26. 10 code += "\n" + indent("painter = painterResource(id = R.drawable.#{raw_src}),", depth + 1)
  27. end
  28. # Content description for accessibility
  29. 10 content_desc = json_data['contentDescription'] || ''
  30. 10 code += "\n" + indent("contentDescription = #{quote(content_desc)},", depth + 1)
  31. # Build modifiers
  32. 10 modifiers = []
  33. # Margins (outer spacing) - must be applied BEFORE size in Compose
  34. 10 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  35. # Size handling
  36. 10 if json_data['width'] && json_data['height']
  37. 1 modifiers << ".size(#{json_data['width']}.dp, #{json_data['height']}.dp)"
  38. 9 elsif json_data['size']
  39. 1 modifiers << ".size(#{json_data['size']}.dp)"
  40. else
  41. 8 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  42. end
  43. # Padding (inner spacing) - applied after size
  44. 10 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  45. 10 modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
  46. 10 modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
  47. 10 code += Helpers::ModifierBuilder.format(modifiers, depth)
  48. # Content mode (case-insensitive)
  49. 10 if json_data['contentMode']
  50. 3 required_imports&.add(:content_scale)
  51. 3 case json_data['contentMode'].to_s.downcase
  52. when 'aspectfill'
  53. 1 code += ",\n" + indent("contentScale = ContentScale.Crop", depth + 1)
  54. when 'aspectfit'
  55. 1 code += ",\n" + indent("contentScale = ContentScale.Fit", depth + 1)
  56. when 'fill', 'scaletofill'
  57. code += ",\n" + indent("contentScale = ContentScale.FillBounds", depth + 1)
  58. when 'center'
  59. 1 code += ",\n" + indent("contentScale = ContentScale.None", depth + 1)
  60. end
  61. end
  62. 10 code += "\n" + indent(")", depth)
  63. 10 code
  64. end
  65. 1 private
  66. 1 def self.to_camel_case(snake_case_string)
  67. return snake_case_string unless snake_case_string.include?('_')
  68. parts = snake_case_string.split('_')
  69. parts[0] + parts[1..-1].map(&:capitalize).join
  70. end
  71. 1 def self.quote(text)
  72. 10 "\"#{text.gsub('"', '\\"')}\""
  73. end
  74. 1 def self.indent(text, level)
  75. 43 return text if level == 0
  76. 23 spaces = ' ' * level
  77. 23 text.split("\n").map { |line|
  78. 23 line.empty? ? line : spaces + line
  79. }.join("\n")
  80. end
  81. end
  82. end
  83. end
  84. end

lib/compose/components/indicator_component.rb

61.67% lines covered

60 relevant lines. 37 lines covered and 23 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class IndicatorComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. # Indicator can be circular or linear based on style
  10. 3 style = json_data['style'] || 'medium'
  11. 3 is_animating = json_data['animating']
  12. # Check if animating is controlled by data binding
  13. 3 show_condition = if is_animating && is_animating.is_a?(String) && is_animating.match(/@\{([^}]+)\}/)
  14. variable = $1
  15. "data.#{variable}"
  16. 3 elsif is_animating == false
  17. 'false'
  18. else
  19. 3 'true'
  20. end
  21. # Wrap in if condition if controlled by animating attribute
  22. 3 if is_animating != nil
  23. code = indent("if (#{show_condition}) {", depth)
  24. actual_depth = depth + 1
  25. else
  26. 3 code = ""
  27. 3 actual_depth = depth
  28. end
  29. # Determine indicator type based on style
  30. 3 if style == 'linear'
  31. code += "\n" if is_animating != nil
  32. code += indent("LinearProgressIndicator(", actual_depth)
  33. else
  34. 3 code += "\n" if is_animating != nil
  35. 3 code += indent("CircularProgressIndicator(", actual_depth)
  36. end
  37. # Build modifiers
  38. 3 modifiers = []
  39. # Size based on style
  40. 3 if style == 'large'
  41. modifiers << ".size(48.dp)"
  42. 3 elsif style == 'small'
  43. modifiers << ".size(16.dp)"
  44. 3 elsif json_data['size']
  45. modifiers << ".size(#{json_data['size']}.dp)"
  46. end
  47. 3 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  48. 3 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  49. 3 modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
  50. # Add weight modifier if in Row or Column
  51. 3 if parent_type == 'Row' || parent_type == 'Column'
  52. modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
  53. end
  54. 3 has_modifiers = modifiers.any?
  55. 3 code += Helpers::ModifierBuilder.format(modifiers, actual_depth) if has_modifiers
  56. # Color
  57. 3 if json_data['color']
  58. color_resolved = Helpers::ResourceResolver.process_color(json_data['color'], required_imports)
  59. if has_modifiers
  60. code += ",\n" + indent("color = #{color_resolved}", actual_depth + 1)
  61. else
  62. code += "\n" + indent("color = #{color_resolved}", actual_depth + 1)
  63. has_modifiers = true
  64. end
  65. end
  66. # Track color for linear progress
  67. 3 if style == 'linear' && json_data['trackColor']
  68. trackcolor_resolved = Helpers::ResourceResolver.process_color(json_data['trackColor'], required_imports)
  69. code += ",\n" + indent("trackColor = #{trackcolor_resolved}", actual_depth + 1)
  70. end
  71. # Stroke width for circular progress
  72. 3 if style != 'linear' && json_data['strokeWidth']
  73. code += ",\n" + indent("strokeWidth = #{json_data['strokeWidth']}.dp", actual_depth + 1)
  74. end
  75. 3 code += "\n" + indent(")", actual_depth)
  76. # Close if condition
  77. 3 if is_animating != nil
  78. code += "\n" + indent("}", depth)
  79. end
  80. 3 code
  81. end
  82. 1 private
  83. 1 def self.indent(text, level)
  84. 6 return text if level == 0
  85. spaces = ' ' * level
  86. text.split("\n").map { |line|
  87. line.empty? ? line : spaces + line
  88. }.join("\n")
  89. end
  90. end
  91. end
  92. end
  93. end

lib/compose/components/networkimage_component.rb

81.13% lines covered

53 relevant lines. 43 lines covered and 10 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class NetworkImageComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. 5 required_imports&.add(:async_image)
  10. # NetworkImage uses 'source' or 'url' for image URL
  11. 5 url = process_data_binding(json_data['source'] || json_data['url'] || json_data['src'] || '')
  12. # Support both 'hint' (primary) and 'placeholder' (alias)
  13. 5 placeholder = json_data['hint'] || json_data['placeholder']
  14. 5 content_description = json_data['contentDescription'] || 'Image'
  15. 5 code = indent("AsyncImage(", depth)
  16. 5 code += "\n" + indent("model = #{url},", depth + 1)
  17. 5 code += "\n" + indent("contentDescription = \"#{content_description}\",", depth + 1)
  18. # Content scale (case-insensitive check)
  19. 5 if json_data['contentMode']
  20. required_imports&.add(:content_scale)
  21. scale = case json_data['contentMode'].to_s.downcase
  22. when 'aspectfit'
  23. 'ContentScale.Fit'
  24. when 'aspectfill'
  25. 'ContentScale.Crop'
  26. when 'fill', 'scaletofill'
  27. 'ContentScale.FillBounds'
  28. when 'center'
  29. 'ContentScale.None'
  30. else
  31. 'ContentScale.Fit'
  32. end
  33. code += "\n" + indent("contentScale = #{scale},", depth + 1)
  34. end
  35. # Placeholder
  36. 5 if placeholder
  37. 1 code += "\n" + indent("placeholder = painterResource(R.drawable.#{placeholder.gsub('.png', '').gsub('.jpg', '')}),", depth + 1)
  38. end
  39. # Build modifiers
  40. # Compose Modifier order (top to bottom = outer to inner):
  41. # 1. margins (outer spacing)
  42. # 2. size
  43. # 3. clip/background
  44. # 4. padding (inner spacing)
  45. 5 modifiers = []
  46. # Margins first (outer spacing, before size)
  47. 5 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  48. # Size
  49. 5 if json_data['size']
  50. modifiers << ".size(#{json_data['size']}.dp)"
  51. else
  52. 5 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  53. end
  54. # clip/background (after size, before padding)
  55. 5 modifiers.concat(Helpers::ModifierBuilder.build_background(json_data, required_imports))
  56. # Padding (inner spacing)
  57. 5 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  58. 5 modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
  59. 5 modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
  60. 5 code += Helpers::ModifierBuilder.format(modifiers, depth)
  61. # Error handling
  62. 5 if json_data['errorImage']
  63. code += ",\n" + indent("error = painterResource(R.drawable.#{json_data['errorImage'].gsub('.png', '').gsub('.jpg', '')})", depth + 1)
  64. end
  65. 5 code += "\n" + indent(")", depth)
  66. 5 code
  67. end
  68. 1 private
  69. 1 def self.process_data_binding(text)
  70. 5 return quote(text) unless text.is_a?(String)
  71. 5 if text.match(/@\{([^}]+)\}/)
  72. 1 variable = $1
  73. 1 "data.#{variable}"
  74. else
  75. 4 quote(text)
  76. end
  77. end
  78. 1 def self.quote(text)
  79. 4 "\"#{text.gsub('"', '\\"')}\""
  80. end
  81. 1 def self.indent(text, level)
  82. 21 return text if level == 0
  83. 11 spaces = ' ' * level
  84. 11 text.split("\n").map { |line|
  85. 11 line.empty? ? line : spaces + line
  86. }.join("\n")
  87. end
  88. end
  89. end
  90. end
  91. end

lib/compose/components/progress_component.rb

97.92% lines covered

48 relevant lines. 47 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class ProgressComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. # Progress can have a value (determinate) or be indeterminate
  10. 15 has_value = json_data['value'] || json_data['bind']
  11. 15 if has_value
  12. # Determinate progress (LinearProgressIndicator)
  13. 3 value = if json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  14. 1 variable = $1
  15. 1 "data.#{variable}.toFloat()"
  16. 2 elsif json_data['value'] && json_data['value'].match(/@\{([^}]+)\}/)
  17. 1 variable = $1
  18. 1 "data.#{variable}.toFloat()"
  19. 1 elsif json_data['value']
  20. 1 "#{json_data['value']}f"
  21. else
  22. '0f'
  23. end
  24. 3 code = indent("LinearProgressIndicator(", depth)
  25. 3 code += "\n" + indent("progress = { #{value} },", depth + 1)
  26. else
  27. # Indeterminate progress
  28. 12 style = json_data['style'] || 'linear'
  29. 12 if style == 'circular' || style == 'large'
  30. 2 code = indent("CircularProgressIndicator(", depth)
  31. else
  32. 10 code = indent("LinearProgressIndicator(", depth)
  33. end
  34. end
  35. # Build modifiers
  36. 15 modifiers = []
  37. 15 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  38. 15 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  39. 15 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  40. 15 modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
  41. 15 code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
  42. # Progress colors
  43. 15 if json_data['progressTintColor'] || json_data['trackTintColor']
  44. 3 colors_params = []
  45. 3 if json_data['progressTintColor']
  46. 2 color_resolved = Helpers::ResourceResolver.process_color(json_data['progressTintColor'], required_imports)
  47. 2 colors_params << "color = #{color_resolved}"
  48. end
  49. 3 if json_data['trackTintColor']
  50. 2 trackcolor_resolved = Helpers::ResourceResolver.process_color(json_data['trackTintColor'], required_imports)
  51. 2 colors_params << "trackColor = #{trackcolor_resolved}"
  52. end
  53. 3 if colors_params.any?
  54. 7 code += ",\n" + colors_params.map { |param| indent(param, depth + 1) }.join(",\n")
  55. end
  56. end
  57. 15 code += "\n" + indent(")", depth)
  58. 15 code
  59. end
  60. 1 private
  61. 1 def self.indent(text, level)
  62. 41 return text if level == 0
  63. 10 spaces = ' ' * level
  64. 10 text.split("\n").map { |line|
  65. 13 line.empty? ? line : spaces + line
  66. }.join("\n")
  67. end
  68. end
  69. end
  70. end
  71. end

lib/compose/components/radio_component.rb

92.52% lines covered

214 relevant lines. 198 lines covered and 16 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class RadioComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. # Handle Radio group with items FIRST (higher priority)
  10. 15 if json_data['items']
  11. 3 return generate_radio_group_with_items(json_data, depth, required_imports, parent_type)
  12. end
  13. # Handle individual Radio item (not a group)
  14. 12 if json_data['group'] || json_data['text']
  15. 6 return generate_radio_item(json_data, depth, required_imports, parent_type)
  16. end
  17. # Radio uses 'bind' for selected value
  18. 6 selected = if json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  19. 4 variable = $1
  20. 4 "data.#{variable}"
  21. else
  22. 2 '""'
  23. end
  24. 6 code = indent("Column(", depth)
  25. # Build modifiers
  26. 6 modifiers = []
  27. 6 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  28. 6 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  29. 6 modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
  30. 6 code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
  31. 6 code += "\n" + indent(") {", depth)
  32. # Radio options
  33. 6 if json_data['options']
  34. 5 if json_data['options'].is_a?(Array)
  35. 4 json_data['options'].each do |option|
  36. 9 option_value = option.is_a?(Hash) ? option['value'] : option
  37. 9 option_label = option.is_a?(Hash) ? option['label'] : option
  38. 9 code += "\n" + indent("Row(", depth + 1)
  39. 9 code += "\n" + indent("verticalAlignment = Alignment.CenterVertically,", depth + 2)
  40. 9 code += "\n" + indent("modifier = Modifier", depth + 2)
  41. 9 code += "\n" + indent(" .fillMaxWidth()", depth + 2)
  42. 9 code += "\n" + indent(" .clickable {", depth + 2)
  43. 9 if json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  44. 7 variable = $1
  45. 7 code += "\n" + indent(" viewModel.updateData(mapOf(\"#{variable}\" to \"#{option_value}\"))", depth + 2)
  46. 2 elsif json_data['onValueChange']
  47. # onValueChange (camelCase) -> binding format only (@{functionName})
  48. 2 if Helpers::ModifierBuilder.is_binding?(json_data['onValueChange'])
  49. 2 method_name = Helpers::ModifierBuilder.extract_binding_property(json_data['onValueChange'])
  50. 2 code += "\n" + indent(" viewModel.#{method_name}(\"#{option_value}\")", depth + 2)
  51. else
  52. code += "\n" + indent(" // ERROR: #{json_data['onValueChange']} - camelCase events require binding format @{functionName}", depth + 2)
  53. end
  54. end
  55. 9 code += "\n" + indent(" }", depth + 2)
  56. 9 code += "\n" + indent(") {", depth + 1)
  57. # RadioButton
  58. 9 code += "\n" + indent("RadioButton(", depth + 2)
  59. 9 code += "\n" + indent("selected = (#{selected} == \"#{option_value}\"),", depth + 3)
  60. 9 code += "\n" + indent("onClick = {", depth + 3)
  61. 9 if json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  62. 7 variable = $1
  63. 7 code += "\n" + indent("viewModel.updateData(mapOf(\"#{variable}\" to \"#{option_value}\"))", depth + 4)
  64. 2 elsif json_data['onValueChange']
  65. # onValueChange (camelCase) -> binding format only (@{functionName})
  66. 2 if Helpers::ModifierBuilder.is_binding?(json_data['onValueChange'])
  67. 2 method_name = Helpers::ModifierBuilder.extract_binding_property(json_data['onValueChange'])
  68. 2 code += "\n" + indent("viewModel.#{method_name}(\"#{option_value}\")", depth + 4)
  69. else
  70. code += "\n" + indent("// ERROR: #{json_data['onValueChange']} - camelCase events require binding format @{functionName}", depth + 4)
  71. end
  72. end
  73. 9 code += "\n" + indent("}", depth + 3)
  74. # RadioButton colors
  75. 9 if json_data['selectedColor'] || json_data['unselectedColor']
  76. 2 required_imports&.add(:radio_colors)
  77. 2 colors_params = []
  78. 2 if json_data['selectedColor']
  79. 2 selectedcolor_resolved = Helpers::ResourceResolver.process_color(json_data['selectedColor'], required_imports)
  80. 2 colors_params << "selectedColor = #{selectedcolor_resolved}"
  81. end
  82. 2 if json_data['unselectedColor']
  83. 2 unselectedcolor_resolved = Helpers::ResourceResolver.process_color(json_data['unselectedColor'], required_imports)
  84. 2 colors_params << "unselectedColor = #{unselectedcolor_resolved}"
  85. end
  86. 2 if colors_params.any?
  87. 2 code += ",\n" + indent("colors = RadioButtonDefaults.colors(", depth + 3)
  88. 6 code += "\n" + colors_params.map { |param| indent(param, depth + 4) }.join(",\n")
  89. 2 code += "\n" + indent(")", depth + 3)
  90. end
  91. end
  92. 9 code += "\n" + indent(")", depth + 2)
  93. # Label text
  94. 9 code += "\n" + indent("Spacer(modifier = Modifier.width(8.dp))", depth + 2)
  95. 9 code += "\n" + indent("Text(\"#{option_label}\")", depth + 2)
  96. 9 code += "\n" + indent("}", depth + 1)
  97. end
  98. 1 elsif json_data['options'].is_a?(String) && json_data['options'].match(/@\{([^}]+)\}/)
  99. # Dynamic options from data binding
  100. 1 options_var = $1
  101. 1 code += "\n" + indent("data.#{options_var}.forEach { option ->", depth + 1)
  102. 1 code += "\n" + indent("Row(", depth + 2)
  103. 1 code += "\n" + indent("verticalAlignment = Alignment.CenterVertically,", depth + 3)
  104. 1 code += "\n" + indent("modifier = Modifier.fillMaxWidth().clickable {", depth + 3)
  105. 1 if json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  106. 1 variable = $1
  107. 1 code += "\n" + indent("viewModel.updateData(mapOf(\"#{variable}\" to option))", depth + 4)
  108. end
  109. 1 code += "\n" + indent("}", depth + 3)
  110. 1 code += "\n" + indent(") {", depth + 2)
  111. 1 code += "\n" + indent("RadioButton(", depth + 3)
  112. 1 code += "\n" + indent("selected = (#{selected} == option),", depth + 4)
  113. 1 code += "\n" + indent("onClick = {", depth + 4)
  114. 1 if json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  115. 1 variable = $1
  116. 1 code += "\n" + indent("viewModel.updateData(mapOf(\"#{variable}\" to option))", depth + 5)
  117. end
  118. 1 code += "\n" + indent("}", depth + 4)
  119. 1 code += "\n" + indent(")", depth + 3)
  120. 1 code += "\n" + indent("Spacer(modifier = Modifier.width(8.dp))", depth + 3)
  121. 1 code += "\n" + indent("Text(option)", depth + 3)
  122. 1 code += "\n" + indent("}", depth + 2)
  123. 1 code += "\n" + indent("}", depth + 1)
  124. end
  125. end
  126. 6 code += "\n" + indent("}", depth)
  127. 6 code
  128. end
  129. 1 private
  130. 1 def self.generate_radio_item(json_data, depth, required_imports, parent_type)
  131. 6 group = json_data['group'] || 'default'
  132. 6 id = json_data['id'] || "radio_#{rand(1000)}"
  133. 6 text = json_data['text'] || ''
  134. # Get the selected state from binding
  135. 6 selected_var = "selectedRadiogroup" # Default variable name
  136. 6 if group.downcase != 'default'
  137. # Use group name as part of the variable
  138. 1 selected_var = "selected#{group.capitalize}"
  139. end
  140. 6 code = indent("Row(", depth)
  141. 6 code += "\n" + indent(" verticalAlignment = Alignment.CenterVertically,", depth)
  142. # Build modifiers
  143. 6 modifiers = []
  144. 6 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  145. 6 if modifiers.any?
  146. code += "\n" + indent(" modifier = Modifier", depth)
  147. modifiers.each do |mod|
  148. code += "\n" + indent(" #{mod}", depth)
  149. end
  150. end
  151. 6 code += "\n" + indent(") {", depth)
  152. # Handle custom icons or default components
  153. # If icon is "circle" or selectedIcon is "checkmark.circle.fill", use default RadioButton
  154. 6 if (json_data['icon'] == 'circle' || !json_data['icon']) &&
  155. (json_data['selectedIcon'] == 'checkmark.circle.fill' || !json_data['selectedIcon'])
  156. # Use default RadioButton for standard radio appearance
  157. 3 code += "\n" + indent(" RadioButton(", depth)
  158. 3 code += "\n" + indent(" selected = data.#{selected_var} == \"#{id}\",", depth)
  159. 3 code += "\n" + indent(" onClick = { viewModel.updateData(mapOf(\"#{selected_var}\" to \"#{id}\")) }", depth)
  160. 3 code += "\n" + indent(" )", depth)
  161. 3 elsif json_data['icon'] == 'square' &&
  162. (json_data['selectedIcon'] == 'checkmark.square.fill' || !json_data['selectedIcon'])
  163. # Use default Checkbox for square appearance
  164. 1 required_imports&.add(:checkbox)
  165. 1 code += "\n" + indent(" Checkbox(", depth)
  166. 1 code += "\n" + indent(" checked = data.#{selected_var} == \"#{id}\",", depth)
  167. 1 code += "\n" + indent(" onCheckedChange = { viewModel.updateData(mapOf(\"#{selected_var}\" to \"#{id}\")) }", depth)
  168. 1 code += "\n" + indent(" )", depth)
  169. 2 elsif json_data['icon'] || json_data['selectedIcon']
  170. # Use IconButton with custom icons only for non-standard icons
  171. 2 required_imports&.add(:icon_button)
  172. 2 required_imports&.add(:icons)
  173. 2 icon = map_icon_name(json_data['icon'] || 'star')
  174. 2 selected_icon = map_icon_name(json_data['selectedIcon'] || 'star.fill')
  175. 2 code += "\n" + indent(" val isSelected = data.#{selected_var} == \"#{id}\"", depth)
  176. 2 code += "\n" + indent(" IconButton(", depth)
  177. 2 code += "\n" + indent(" onClick = { viewModel.updateData(mapOf(\"#{selected_var}\" to \"#{id}\")) }", depth)
  178. 2 code += "\n" + indent(" ) {", depth)
  179. 2 code += "\n" + indent(" Icon(", depth)
  180. 2 code += "\n" + indent(" imageVector = if (isSelected) #{selected_icon} else #{icon},", depth)
  181. 2 code += "\n" + indent(" contentDescription = \"#{text}\",", depth)
  182. 2 if json_data['selectedColor'] || json_data['tintColor']
  183. 1 color = json_data['selectedColor'] || json_data['tintColor']
  184. 1 selected_color = Helpers::ResourceResolver.process_color(color, required_imports)
  185. 1 code += "\n" + indent(" tint = if (isSelected) #{selected_color} else Color.Gray", depth)
  186. else
  187. 1 code += "\n" + indent(" tint = if (isSelected) MaterialTheme.colorScheme.primary else Color.Gray", depth)
  188. end
  189. 2 code += "\n" + indent(" )", depth)
  190. 2 code += "\n" + indent(" }", depth)
  191. else
  192. # Default RadioButton
  193. code += "\n" + indent(" RadioButton(", depth)
  194. code += "\n" + indent(" selected = data.#{selected_var} == \"#{id}\",", depth)
  195. code += "\n" + indent(" onClick = { viewModel.updateData(mapOf(\"#{selected_var}\" to \"#{id}\")) }", depth)
  196. code += "\n" + indent(" )", depth)
  197. end
  198. # Add text label
  199. 6 if text && !text.empty?
  200. 6 code += "\n" + indent(" Spacer(modifier = Modifier.width(8.dp))", depth)
  201. # Add text with color
  202. 6 if json_data['fontColor'] || json_data['textColor']
  203. 1 text_color = json_data['fontColor'] || json_data['textColor']
  204. 1 color_resolved = Helpers::ResourceResolver.process_color(text_color, required_imports)
  205. 1 code += "\n" + indent(" Text(\"#{text}\", color = #{color_resolved})", depth)
  206. else
  207. # Default to black color
  208. 5 code += "\n" + indent(" Text(\"#{text}\", color = Color.Black)", depth)
  209. end
  210. end
  211. 6 code += "\n" + indent("}", depth)
  212. 6 code
  213. end
  214. 1 def self.generate_radio_group_with_items(json_data, depth, required_imports, parent_type)
  215. 3 items = json_data['items']
  216. 3 selected_value = json_data['selectedValue']
  217. # Add required import for clickable
  218. 3 required_imports&.add(:clickable)
  219. # Extract binding variable
  220. 3 selected_var = if selected_value && selected_value.match(/@\{([^}]+)\}/)
  221. 3 "data.#{$1}"
  222. else
  223. '""'
  224. end
  225. 3 code = indent("Column(", depth)
  226. # Build modifiers
  227. 3 modifiers = []
  228. 3 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  229. 3 if modifiers.any?
  230. code += "\n" + indent(" modifier = Modifier", depth)
  231. modifiers.each do |mod|
  232. code += "\n" + indent(" #{mod}", depth)
  233. end
  234. end
  235. 3 code += "\n" + indent(") {", depth)
  236. # Add label if present
  237. 3 if json_data['text']
  238. 1 if json_data['fontColor'] || json_data['textColor']
  239. text_color = json_data['fontColor'] || json_data['textColor']
  240. color_resolved = Helpers::ResourceResolver.process_color(text_color, required_imports)
  241. code += "\n" + indent(" Text(\"#{json_data['text']}\", color = #{color_resolved})", depth)
  242. else
  243. # Default to black color
  244. 1 code += "\n" + indent(" Text(\"#{json_data['text']}\", color = Color.Black)", depth)
  245. end
  246. 1 code += "\n" + indent(" Spacer(modifier = Modifier.height(8.dp))", depth)
  247. end
  248. # Generate radio items
  249. 3 items.each do |item|
  250. 7 code += "\n" + indent(" Row(", depth)
  251. 7 code += "\n" + indent(" verticalAlignment = Alignment.CenterVertically,", depth)
  252. 7 code += "\n" + indent(" modifier = Modifier", depth)
  253. 7 code += "\n" + indent(" .fillMaxWidth()", depth)
  254. 7 code += "\n" + indent(" .clickable {", depth)
  255. 7 if selected_value && selected_value.match(/@\{([^}]+)\}/)
  256. 7 variable = $1
  257. 7 code += "\n" + indent(" viewModel.updateData(mapOf(\"#{variable}\" to \"#{item}\"))", depth)
  258. end
  259. 7 code += "\n" + indent(" }", depth)
  260. 7 code += "\n" + indent(" ) {", depth)
  261. 7 code += "\n" + indent(" RadioButton(", depth)
  262. 7 code += "\n" + indent(" selected = #{selected_var} == \"#{item}\",", depth)
  263. 7 code += "\n" + indent(" onClick = {", depth)
  264. 7 if selected_value && selected_value.match(/@\{([^}]+)\}/)
  265. 7 variable = $1
  266. 7 code += "\n" + indent(" viewModel.updateData(mapOf(\"#{variable}\" to \"#{item}\"))", depth)
  267. end
  268. 7 code += "\n" + indent(" }", depth)
  269. 7 code += "\n" + indent(" )", depth)
  270. 7 code += "\n" + indent(" Spacer(modifier = Modifier.width(8.dp))", depth)
  271. # Add text with black color
  272. 7 if json_data['fontColor'] || json_data['textColor']
  273. 2 text_color = json_data['fontColor'] || json_data['textColor']
  274. 2 color_resolved = Helpers::ResourceResolver.process_color(text_color, required_imports)
  275. 2 code += "\n" + indent(" Text(\"#{item}\", color = #{color_resolved})", depth)
  276. else
  277. # Default to black color
  278. 5 code += "\n" + indent(" Text(\"#{item}\", color = Color.Black)", depth)
  279. end
  280. 7 code += "\n" + indent(" }", depth)
  281. end
  282. 3 code += "\n" + indent("}", depth)
  283. 3 code
  284. end
  285. 1 def self.map_icon_name(icon_name)
  286. # Map iOS SF Symbols to Material Icons
  287. 9 icon_map = {
  288. 'circle' => 'Icons.Outlined.PanoramaFishEye', # Using PanoramaFishEye as it's a hollow circle
  289. 'checkmark.circle.fill' => 'Icons.Filled.CheckCircle',
  290. 'star' => 'Icons.Outlined.Star',
  291. 'star.fill' => 'Icons.Filled.Star',
  292. 'heart' => 'Icons.Outlined.FavoriteBorder',
  293. 'heart.fill' => 'Icons.Filled.Favorite',
  294. 'square' => 'Icons.Outlined.CheckBoxOutlineBlank',
  295. 'checkmark.square.fill' => 'Icons.Default.CheckBox' # Use Default.CheckBox instead of Filled.CheckBox
  296. }
  297. 9 icon_map[icon_name] || 'Icons.Outlined.Star' # Default fallback to star
  298. end
  299. 1 def self.indent(text, level)
  300. 400 return text if level == 0
  301. 179 spaces = ' ' * level
  302. 179 text.split("\n").map { |line|
  303. 179 line.empty? ? line : spaces + line
  304. }.join("\n")
  305. end
  306. end
  307. end
  308. end
  309. end

lib/compose/components/scrollview_component.rb

100.0% lines covered

44 relevant lines. 44 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 module KjuiTools
  4. 1 module Compose
  5. 1 module Components
  6. 1 class ScrollViewComponent
  7. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  8. # スクロール方向の判定
  9. # horizontalScroll属性、orientation属性、またはchild要素の配置から判定
  10. 13 is_horizontal = false
  11. # 1. horizontalScroll属性を最優先
  12. 13 if json_data.key?('horizontalScroll')
  13. 2 is_horizontal = json_data['horizontalScroll']
  14. # 2. orientation属性を次に確認
  15. 11 elsif json_data.key?('orientation')
  16. 1 is_horizontal = json_data['orientation'] == 'horizontal'
  17. # 3. child要素の配置から判定
  18. 10 elsif json_data['child']
  19. 3 children = json_data['child']
  20. # childを配列として扱う
  21. 3 children = [children] unless children.is_a?(Array)
  22. # 配列の中から最初のViewコンポーネントを探す
  23. 6 first_view = children.find { |child| child.is_a?(Hash) && child['type'] == 'View' }
  24. 3 if first_view
  25. 1 is_horizontal = first_view['orientation'] == 'horizontal'
  26. end
  27. end
  28. # keyboardAvoidance属性の確認(デフォルトはtrue)
  29. 13 keyboard_avoidance = json_data['keyboardAvoidance'] != false
  30. 13 if is_horizontal
  31. 4 required_imports&.add(:lazy_row)
  32. 4 code = indent("LazyRow(", depth)
  33. else
  34. 9 required_imports&.add(:lazy_column)
  35. 9 code = indent("LazyColumn(", depth)
  36. end
  37. # Build modifiers
  38. 13 modifiers = []
  39. 13 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  40. 13 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  41. 13 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  42. 13 modifiers.concat(Helpers::ModifierBuilder.build_background(json_data, required_imports))
  43. 13 modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
  44. # Apply keyboard avoidance at the end of modifier chain
  45. 13 if keyboard_avoidance
  46. 11 required_imports&.add(:ime_padding)
  47. 11 modifiers << ".imePadding()"
  48. end
  49. 13 code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
  50. 13 code += "\n" + indent(") {", depth)
  51. 13 code += "\n" + indent("item {", depth + 1)
  52. # Process children
  53. 13 children = json_data['child'] || []
  54. 13 children = [children] unless children.is_a?(Array)
  55. # Return structure for parent to process children
  56. {
  57. 13 code: code,
  58. children: children,
  59. closing: "\n" + indent("}", depth + 1) + "\n" + indent("}", depth),
  60. json_data: json_data
  61. }
  62. end
  63. 1 private
  64. 1 def self.indent(text, level)
  65. 65 return text if level == 0
  66. 26 spaces = ' ' * level
  67. 26 text.split("\n").map { |line|
  68. 26 line.empty? ? line : spaces + line
  69. }.join("\n")
  70. end
  71. end
  72. end
  73. end
  74. end

lib/compose/components/segment_component.rb

78.92% lines covered

166 relevant lines. 131 lines covered and 35 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class SegmentComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. 22 required_imports&.add(:segment)
  10. # Segment uses 'selectedIndex' or 'bind' for selected index
  11. # Track if the selected index is dynamic (from data binding) or static
  12. 22 is_dynamic_index = false
  13. 22 selected_index = if json_data['selectedIndex']
  14. 5 if json_data['selectedIndex'].is_a?(String) && json_data['selectedIndex'].match(/@\{([^}]+)\}/)
  15. 3 variable = $1
  16. 3 is_dynamic_index = true
  17. 3 "data.#{variable}"
  18. else
  19. # Direct integer value - keep as integer for proper comparison
  20. 2 json_data['selectedIndex'].to_i
  21. end
  22. 17 elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  23. 1 variable = $1
  24. 1 is_dynamic_index = true
  25. 1 "data.#{variable}"
  26. else
  27. 16 0 # Default to 0 as integer
  28. end
  29. # Support both 'items' and 'segments' attribute names
  30. 22 segments = json_data['items'] || json_data['segments'] || []
  31. 22 code = indent("Segment(", depth)
  32. # For display in Segment parameter, always output as string
  33. 22 selected_tab_param = is_dynamic_index ? selected_index : selected_index.to_s
  34. 22 code += "\n" + indent("selectedTabIndex = #{selected_tab_param},", depth + 1)
  35. # Add enabled state if specified
  36. 22 if json_data.key?('enabled')
  37. 2 enabled_value = json_data['enabled']
  38. 2 if enabled_value.is_a?(String) && enabled_value.match(/@\{([^}]+)\}/)
  39. 1 code += "\n" + indent("enabled = data.#{$1},", depth + 1)
  40. else
  41. 1 code += "\n" + indent("enabled = #{enabled_value},", depth + 1)
  42. end
  43. end
  44. # Tab colors - only add if specified, otherwise use defaults from Configuration
  45. 22 colors_params = []
  46. # Background color (containerColor)
  47. 22 if json_data['backgroundColor']
  48. 1 bg_color = Helpers::ResourceResolver.process_color(json_data['backgroundColor'], required_imports)
  49. 1 colors_params << "containerColor = #{bg_color}"
  50. end
  51. # Normal text color (contentColor) - for unselected tabs
  52. 22 if json_data['normalColor']
  53. 3 normal_color = Helpers::ResourceResolver.process_color(json_data['normalColor'], required_imports)
  54. 3 colors_params << "contentColor = #{normal_color}"
  55. end
  56. # Selected text color (selectedContentColor)
  57. 22 if json_data['selectedColor'] || json_data['tintColor'] || json_data['selectedSegmentTintColor']
  58. 4 color = json_data['selectedColor'] || json_data['tintColor'] || json_data['selectedSegmentTintColor']
  59. 4 selected_color = Helpers::ResourceResolver.process_color(color, required_imports)
  60. 4 colors_params << "selectedContentColor = #{selected_color}"
  61. end
  62. # Indicator color - only if specified
  63. 22 if json_data['indicatorColor']
  64. 1 indicator_color = Helpers::ResourceResolver.process_color(json_data['indicatorColor'], required_imports)
  65. 1 colors_params << "indicatorColor = #{indicator_color}"
  66. end
  67. 22 if colors_params.any?
  68. 7 code += "\n" + indent(colors_params.join(",\n"), depth + 1) + ","
  69. end
  70. # Build modifiers
  71. 22 modifiers = []
  72. 22 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  73. 22 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  74. 22 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  75. 22 modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
  76. 22 code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
  77. 22 code += "\n" + indent(") {", depth)
  78. # Generate tabs
  79. 22 if segments.is_a?(Array)
  80. 21 segments.each_with_index do |segment, index|
  81. 41 code += "\n" + indent("Tab(", depth + 1)
  82. # For selected comparison, handle both dynamic and static cases
  83. 41 selected_comparison = is_dynamic_index ? "(#{selected_index} == #{index})" : (selected_index == index).to_s
  84. 41 code += "\n" + indent("selected = #{selected_comparison},", depth + 2)
  85. # Add enabled state to Tab if segment is disabled
  86. 41 if json_data.key?('enabled')
  87. 4 enabled_value = json_data['enabled']
  88. 4 if enabled_value.is_a?(String) && enabled_value.match(/@\{([^}]+)\}/)
  89. 2 code += "\n" + indent("enabled = data.#{$1},", depth + 2)
  90. else
  91. 2 code += "\n" + indent("enabled = #{enabled_value},", depth + 2)
  92. end
  93. end
  94. 41 code += "\n" + indent("onClick = {", depth + 2)
  95. # Check if we have a binding variable
  96. 41 has_binding = false
  97. 41 binding_variable = nil
  98. 41 if json_data['selectedIndex'] && json_data['selectedIndex'].is_a?(String) && json_data['selectedIndex'].match(/@\{([^}]+)\}/)
  99. 6 has_binding = true
  100. 6 binding_variable = $1
  101. 35 elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  102. 2 has_binding = true
  103. 2 binding_variable = $1
  104. end
  105. # Generate onClick handler
  106. # onValueChange (camelCase) -> binding format only (@{functionName})
  107. 41 if json_data['onValueChange']
  108. 2 if Helpers::ModifierBuilder.is_binding?(json_data['onValueChange'])
  109. 2 method_name = Helpers::ModifierBuilder.extract_binding_property(json_data['onValueChange'])
  110. 2 code += "\n" + indent("viewModel.#{method_name}(#{index})", depth + 3)
  111. else
  112. code += "\n" + indent("// ERROR: #{json_data['onValueChange']} - camelCase events require binding format @{functionName}", depth + 3)
  113. end
  114. 39 elsif has_binding
  115. # Update the bound variable
  116. 8 code += "\n" + indent("viewModel.updateData(mapOf(\"#{binding_variable}\" to #{index}))", depth + 3)
  117. else
  118. # No action if selectedIndex is a static value with no binding
  119. 31 code += "\n" + indent("// Static selected index", depth + 3)
  120. end
  121. 41 code += "\n" + indent("},", depth + 2)
  122. # Generate text with color based on selection
  123. # Store color info for later use
  124. 41 normal_color = json_data['normalColor']
  125. 41 selected_color = json_data['selectedColor'] || json_data['tintColor'] || json_data['selectedSegmentTintColor']
  126. 41 if normal_color || selected_color
  127. # Need to handle text color based on selection
  128. 10 code += "\n" + indent("text = {", depth + 2)
  129. 10 code += "\n" + indent("Text(", depth + 3)
  130. 10 code += "\n" + indent("\"#{segment}\",", depth + 4)
  131. # Use conditional color based on selection
  132. 10 if is_dynamic_index
  133. 2 if selected_color && normal_color
  134. 2 selected_resolved = Helpers::ResourceResolver.process_color(selected_color, required_imports)
  135. 2 normal_resolved = Helpers::ResourceResolver.process_color(normal_color, required_imports)
  136. 2 code += "\n" + indent("color = if (#{selected_index} == #{index}) #{selected_resolved} else #{normal_resolved}", depth + 4)
  137. elsif selected_color
  138. selected_resolved = Helpers::ResourceResolver.process_color(selected_color, required_imports)
  139. code += "\n" + indent("color = if (#{selected_index} == #{index}) #{selected_resolved} else Color.Unspecified", depth + 4)
  140. elsif normal_color
  141. normal_resolved = Helpers::ResourceResolver.process_color(normal_color, required_imports)
  142. code += "\n" + indent("color = if (#{selected_index} == #{index}) Color.Unspecified else #{normal_resolved}", depth + 4)
  143. end
  144. else
  145. # Static index
  146. 8 is_selected = (selected_index == index)
  147. 8 if is_selected && selected_color
  148. 3 selected_resolved = Helpers::ResourceResolver.process_color(selected_color, required_imports)
  149. 3 code += "\n" + indent("color = #{selected_resolved}", depth + 4)
  150. 5 elsif !is_selected && normal_color
  151. 2 normal_resolved = Helpers::ResourceResolver.process_color(normal_color, required_imports)
  152. 2 code += "\n" + indent("color = #{normal_resolved}", depth + 4)
  153. end
  154. end
  155. 10 code += "\n" + indent(")", depth + 3)
  156. 10 code += "\n" + indent("}", depth + 2)
  157. else
  158. 31 code += "\n" + indent("text = { Text(\"#{segment}\") }", depth + 2)
  159. end
  160. 41 code += "\n" + indent(")", depth + 1)
  161. end
  162. 1 elsif segments.is_a?(String) && segments.match(/@\{([^}]+)\}/)
  163. # Dynamic segments from data binding
  164. 1 segments_var = $1
  165. 1 code += "\n" + indent("data.#{segments_var}.forEachIndexed { index, segment ->", depth + 1)
  166. 1 code += "\n" + indent("Tab(", depth + 2)
  167. # For dynamic segments, selected_index comparison depends on whether the index itself is dynamic
  168. 1 selected_comparison = is_dynamic_index ? "(#{selected_index} == index)" : "(#{selected_index} == index)"
  169. 1 code += "\n" + indent("selected = #{selected_comparison},", depth + 3)
  170. # Add enabled state to Tab if segment is disabled
  171. 1 if json_data.key?('enabled')
  172. enabled_value = json_data['enabled']
  173. if enabled_value.is_a?(String) && enabled_value.match(/@\{([^}]+)\}/)
  174. code += "\n" + indent("enabled = data.#{$1},", depth + 3)
  175. else
  176. code += "\n" + indent("enabled = #{enabled_value},", depth + 3)
  177. end
  178. end
  179. 1 code += "\n" + indent("onClick = {", depth + 3)
  180. # Check if we have a binding variable
  181. 1 has_binding = false
  182. 1 binding_variable = nil
  183. 1 if json_data['selectedIndex'] && json_data['selectedIndex'].is_a?(String) && json_data['selectedIndex'].match(/@\{([^}]+)\}/)
  184. has_binding = true
  185. binding_variable = $1
  186. 1 elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  187. has_binding = true
  188. binding_variable = $1
  189. end
  190. # Generate onClick handler
  191. # onValueChange (camelCase) -> binding format only (@{functionName})
  192. 1 if json_data['onValueChange']
  193. if Helpers::ModifierBuilder.is_binding?(json_data['onValueChange'])
  194. method_name = Helpers::ModifierBuilder.extract_binding_property(json_data['onValueChange'])
  195. code += "\n" + indent("viewModel.#{method_name}(index)", depth + 4)
  196. else
  197. code += "\n" + indent("// ERROR: #{json_data['onValueChange']} - camelCase events require binding format @{functionName}", depth + 4)
  198. end
  199. 1 elsif has_binding
  200. # Update the bound variable
  201. code += "\n" + indent("viewModel.updateData(mapOf(\"#{binding_variable}\" to index))", depth + 4)
  202. else
  203. # No action if selectedIndex is a static value with no binding
  204. 1 code += "\n" + indent("// Static selected index", depth + 4)
  205. end
  206. 1 code += "\n" + indent("},", depth + 3)
  207. # Generate text with color based on selection for dynamic segments
  208. 1 normal_color = json_data['normalColor']
  209. 1 selected_color = json_data['selectedColor'] || json_data['tintColor'] || json_data['selectedSegmentTintColor']
  210. 1 if normal_color || selected_color
  211. code += "\n" + indent("text = {", depth + 3)
  212. code += "\n" + indent("Text(", depth + 4)
  213. code += "\n" + indent("segment,", depth + 5)
  214. # Use conditional color based on selection
  215. if selected_color && normal_color
  216. selected_resolved = Helpers::ResourceResolver.process_color(selected_color, required_imports)
  217. normal_resolved = Helpers::ResourceResolver.process_color(normal_color, required_imports)
  218. code += "\n" + indent("color = if (#{selected_comparison}) #{selected_resolved} else #{normal_resolved}", depth + 5)
  219. elsif selected_color
  220. selected_resolved = Helpers::ResourceResolver.process_color(selected_color, required_imports)
  221. code += "\n" + indent("color = if (#{selected_comparison}) #{selected_resolved} else Color.Unspecified", depth + 5)
  222. elsif normal_color
  223. normal_resolved = Helpers::ResourceResolver.process_color(normal_color, required_imports)
  224. code += "\n" + indent("color = if (#{selected_comparison}) Color.Unspecified else #{normal_resolved}", depth + 5)
  225. end
  226. code += "\n" + indent(")", depth + 4)
  227. code += "\n" + indent("}", depth + 3)
  228. else
  229. 1 code += "\n" + indent("text = { Text(segment) }", depth + 3)
  230. end
  231. 1 code += "\n" + indent(")", depth + 2)
  232. 1 code += "\n" + indent("}", depth + 1)
  233. end
  234. 22 code += "\n" + indent("}", depth)
  235. 22 code
  236. end
  237. 1 private
  238. 1 def self.indent(text, level)
  239. 446 return text if level == 0
  240. 379 spaces = ' ' * level
  241. 379 text.split("\n").map { |line|
  242. 381 line.empty? ? line : spaces + line
  243. }.join("\n")
  244. end
  245. end
  246. end
  247. end
  248. end

lib/compose/components/selectbox_component.rb

89.17% lines covered

120 relevant lines. 107 lines covered and 13 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class SelectBoxComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. 28 required_imports&.add(:selectbox_component)
  10. # Check if this is a date picker
  11. 28 is_date_picker = json_data['selectItemType'] == 'Date'
  12. # SelectBox uses 'selectedItem', 'selectedDate', or 'bind' for selected value
  13. # For date pickers, selectedDate takes priority
  14. 28 selected = if is_date_picker && json_data['selectedDate'] && json_data['selectedDate'].match(/@\{([^}]+)\}/)
  15. variable = $1
  16. "data.#{variable}"
  17. 28 elsif json_data['selectedItem'] && json_data['selectedItem'].match(/@\{([^}]+)\}/)
  18. 1 variable = $1
  19. 1 "data.#{variable}"
  20. 27 elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  21. 1 variable = $1
  22. 1 "data.#{variable}"
  23. else
  24. 26 '""'
  25. end
  26. # Use DateSelectBox for date type
  27. 28 if is_date_picker
  28. 9 required_imports&.add(:date_selectbox_component)
  29. 9 code = indent("DateSelectBox(", depth)
  30. else
  31. 19 code = indent("SelectBox(", depth)
  32. end
  33. 28 code += "\n" + indent("value = #{selected},", depth + 1)
  34. # Handle onValueChange callback
  35. # For date pickers, check selectedDate first
  36. 28 binding_variable = nil
  37. 28 if is_date_picker && json_data['selectedDate'] && json_data['selectedDate'].match(/@\{([^}]+)\}/)
  38. binding_variable = $1
  39. 28 elsif json_data['selectedItem'] && json_data['selectedItem'].match(/@\{([^}]+)\}/)
  40. 1 binding_variable = $1
  41. 27 elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  42. 1 binding_variable = $1
  43. end
  44. 28 if binding_variable
  45. 2 code += "\n" + indent("onValueChange = { newValue ->", depth + 1)
  46. 2 code += "\n" + indent("viewModel.updateData(mapOf(\"#{binding_variable}\" to newValue))", depth + 2)
  47. 2 code += "\n" + indent("},", depth + 1)
  48. else
  49. 26 code += "\n" + indent("onValueChange = { },", depth + 1)
  50. end
  51. # For date picker, add date-specific parameters
  52. 28 if is_date_picker
  53. # Date picker mode (date, time, dateAndTime)
  54. 9 if json_data['datePickerMode']
  55. 1 code += "\n" + indent("datePickerMode = \"#{json_data['datePickerMode']}\",", depth + 1)
  56. end
  57. # Date picker style
  58. 9 if json_data['datePickerStyle']
  59. 1 code += "\n" + indent("datePickerStyle = \"#{json_data['datePickerStyle']}\",", depth + 1)
  60. end
  61. # Date format (or dateStringFormat)
  62. 9 date_format = json_data['dateFormat'] || json_data['dateStringFormat']
  63. 9 if date_format
  64. 2 code += "\n" + indent("dateFormat = \"#{date_format}\",", depth + 1)
  65. end
  66. # Minute interval for time pickers
  67. 9 if json_data['minuteInterval']
  68. 1 code += "\n" + indent("minuteInterval = #{json_data['minuteInterval']},", depth + 1)
  69. end
  70. # Minimum date
  71. 9 if json_data['minimumDate']
  72. 1 code += "\n" + indent("minimumDate = \"#{json_data['minimumDate']}\",", depth + 1)
  73. end
  74. # Maximum date
  75. 9 if json_data['maximumDate']
  76. 1 code += "\n" + indent("maximumDate = \"#{json_data['maximumDate']}\",", depth + 1)
  77. end
  78. else
  79. # Options (use 'items' or 'options') - only for non-date SelectBox
  80. 19 options_data = json_data['items'] || json_data['options']
  81. 19 if options_data
  82. 6 if options_data.is_a?(String) && options_data.match(/@\{([^}]+)\}/)
  83. # Dynamic options from data binding
  84. 1 options_var = $1
  85. 1 code += "\n" + indent("options = data.#{options_var},", depth + 1)
  86. 5 elsif options_data.is_a?(Array)
  87. # Static options array
  88. 5 options_list = options_data.map do |option|
  89. 12 if option.is_a?(Hash)
  90. 2 "\"#{option['label'] || option['value']}\""
  91. else
  92. 10 "\"#{option}\""
  93. end
  94. end.join(", ")
  95. 5 code += "\n" + indent("options = listOf(#{options_list}),", depth + 1)
  96. else
  97. code += "\n" + indent("options = emptyList(),", depth + 1)
  98. end
  99. else
  100. 13 code += "\n" + indent("options = emptyList(),", depth + 1)
  101. end
  102. end
  103. # Add placeholder/hint if specified
  104. 28 if json_data['hint']
  105. 1 code += "\n" + indent("placeholder = \"#{json_data['hint']}\",", depth + 1)
  106. 27 elsif json_data['placeholder']
  107. 1 code += "\n" + indent("placeholder = \"#{json_data['placeholder']}\",", depth + 1)
  108. end
  109. # Add enabled state if specified
  110. 28 if json_data['disabled']
  111. 1 code += "\n" + indent("enabled = false,", depth + 1)
  112. 27 elsif json_data['enabled'] == false
  113. 1 code += "\n" + indent("enabled = false,", depth + 1)
  114. end
  115. # Add style parameters
  116. 28 if json_data['background']
  117. 1 bg_color = Helpers::ResourceResolver.process_color(json_data['background'], required_imports)
  118. 1 code += "\n" + indent("backgroundColor = #{bg_color},", depth + 1)
  119. end
  120. 28 if json_data['borderColor']
  121. 1 border_color = Helpers::ResourceResolver.process_color(json_data['borderColor'], required_imports)
  122. 1 code += "\n" + indent("borderColor = #{border_color},", depth + 1)
  123. end
  124. 28 if json_data['fontColor']
  125. 1 text_color = Helpers::ResourceResolver.process_color(json_data['fontColor'], required_imports)
  126. 1 code += "\n" + indent("textColor = #{text_color},", depth + 1)
  127. end
  128. 28 if json_data['hintColor']
  129. 1 hint_color = Helpers::ResourceResolver.process_color(json_data['hintColor'], required_imports)
  130. 1 code += "\n" + indent("hintColor = #{hint_color},", depth + 1)
  131. end
  132. 28 if json_data['cornerRadius']
  133. 1 code += "\n" + indent("cornerRadius = #{json_data['cornerRadius']},", depth + 1)
  134. end
  135. # Font styling
  136. 28 if json_data['fontSize']
  137. code += "\n" + indent("fontSize = #{json_data['fontSize']},", depth + 1)
  138. end
  139. 28 if json_data['font']
  140. font_weight = case json_data['font'].to_s.downcase
  141. when 'bold'
  142. 'FontWeight.Bold'
  143. when 'semibold'
  144. 'FontWeight.SemiBold'
  145. when 'medium'
  146. 'FontWeight.Medium'
  147. when 'light'
  148. 'FontWeight.Light'
  149. when 'thin'
  150. 'FontWeight.Thin'
  151. else
  152. 'FontWeight.Normal'
  153. end
  154. code += "\n" + indent("fontWeight = #{font_weight},", depth + 1)
  155. end
  156. # Add cancel button background color if specified
  157. 28 if json_data['cancelButtonBackgroundColor']
  158. 1 cancel_bg = Helpers::ResourceResolver.process_color(json_data['cancelButtonBackgroundColor'], required_imports)
  159. 1 code += "\n" + indent("cancelButtonBackgroundColor = #{cancel_bg},", depth + 1)
  160. end
  161. # Add cancel button text color if specified
  162. 28 if json_data['cancelButtonTextColor']
  163. 1 cancel_text = Helpers::ResourceResolver.process_color(json_data['cancelButtonTextColor'], required_imports)
  164. 1 code += "\n" + indent("cancelButtonTextColor = #{cancel_text},", depth + 1)
  165. end
  166. # Build modifiers
  167. 28 modifiers = []
  168. # Ensure fillMaxWidth if width is not specified for date pickers
  169. 28 if is_date_picker && !json_data['width']
  170. 9 modifiers << ".fillMaxWidth()"
  171. end
  172. 28 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  173. 28 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  174. 28 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  175. 28 modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
  176. 28 modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
  177. 28 if modifiers.any? && !modifiers.include?('SKIP_RENDER')
  178. 9 code += Helpers::ModifierBuilder.format(modifiers, depth)
  179. end
  180. 28 code += "\n" + indent(")", depth)
  181. 28 code
  182. end
  183. 1 private
  184. 1 def self.indent(text, level)
  185. 155 return text if level == 0
  186. 98 spaces = ' ' * level
  187. 98 text.split("\n").map { |line|
  188. 98 line.empty? ? line : spaces + line
  189. }.join("\n")
  190. end
  191. end
  192. end
  193. end
  194. end

lib/compose/components/slider_component.rb

98.61% lines covered

72 relevant lines. 71 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class SliderComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. # Slider uses 'value' or 'bind' for binding
  10. 23 value = if json_data['value']
  11. 2 if json_data['value'].is_a?(String) && json_data['value'].match(/@\{([^}]+)\}/)
  12. 1 variable = $1
  13. 1 "data.#{variable}.toFloat()"
  14. else
  15. # Direct value
  16. 1 "#{json_data['value']}f"
  17. end
  18. 21 elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  19. 1 variable = $1
  20. 1 "data.#{variable}.toFloat()"
  21. else
  22. 20 '0f'
  23. end
  24. # Support both naming conventions for min/max
  25. 23 min_value = json_data['minimumValue'] || json_data['min'] || 0
  26. 23 max_value = json_data['maximumValue'] || json_data['max'] || 100
  27. 23 code = indent("Slider(", depth)
  28. 23 code += "\n" + indent("value = #{value},", depth + 1)
  29. # onValueChange handler
  30. 23 binding_variable = nil
  31. 23 if json_data['value'] && json_data['value'].is_a?(String) && json_data['value'].match(/@\{([^}]+)\}/)
  32. 1 binding_variable = $1
  33. 22 elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  34. 1 binding_variable = $1
  35. end
  36. 23 if json_data['onValueChange']
  37. # onValueChange (camelCase) -> binding format only (@{functionName})
  38. 1 if Helpers::ModifierBuilder.is_binding?(json_data['onValueChange'])
  39. 1 method_name = Helpers::ModifierBuilder.extract_binding_property(json_data['onValueChange'])
  40. 1 code += "\n" + indent("onValueChange = { viewModel.#{method_name}(it) },", depth + 1)
  41. else
  42. code += "\n" + indent("onValueChange = { // ERROR: #{json_data['onValueChange']} - camelCase events require binding format @{functionName} },", depth + 1)
  43. end
  44. 22 elsif binding_variable
  45. # Update the bound variable - check if it's Int or Double/Float based on the data type
  46. 2 code += "\n" + indent("onValueChange = { newValue -> viewModel.updateData(mapOf(\"#{binding_variable}\" to newValue.toDouble())) },", depth + 1)
  47. else
  48. 20 code += "\n" + indent("onValueChange = { },", depth + 1)
  49. end
  50. # Value range
  51. 23 code += "\n" + indent("valueRange = #{min_value}f..#{max_value}f,", depth + 1)
  52. # Steps
  53. 23 if json_data['step'] && json_data['step'] > 0
  54. 1 steps = ((max_value - min_value) / json_data['step'].to_f).to_i - 1
  55. 1 code += "\n" + indent("steps = #{steps},", depth + 1) if steps > 0
  56. end
  57. # Build modifiers
  58. 23 modifiers = []
  59. 23 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  60. 23 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  61. 23 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  62. 23 modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
  63. 23 code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
  64. # Slider colors
  65. 23 if json_data['minimumTrackTintColor'] || json_data['maximumTrackTintColor'] || json_data['thumbTintColor']
  66. 4 required_imports&.add(:slider_colors)
  67. 4 colors_params = []
  68. 4 if json_data['thumbTintColor']
  69. 2 thumbcolor_resolved = Helpers::ResourceResolver.process_color(json_data['thumbTintColor'], required_imports)
  70. 2 colors_params << "thumbColor = #{thumbcolor_resolved}"
  71. end
  72. 4 if json_data['minimumTrackTintColor']
  73. 2 activetrackcolor_resolved = Helpers::ResourceResolver.process_color(json_data['minimumTrackTintColor'], required_imports)
  74. 2 colors_params << "activeTrackColor = #{activetrackcolor_resolved}"
  75. end
  76. 4 if json_data['maximumTrackTintColor']
  77. 2 inactivetrackcolor_resolved = Helpers::ResourceResolver.process_color(json_data['maximumTrackTintColor'], required_imports)
  78. 2 colors_params << "inactiveTrackColor = #{inactivetrackcolor_resolved}"
  79. end
  80. 4 if colors_params.any?
  81. 4 code += ",\n" + indent("colors = SliderDefaults.colors(", depth + 1)
  82. 10 code += "\n" + colors_params.map { |param| indent(param, depth + 2) }.join(",\n")
  83. 4 code += "\n" + indent(")", depth + 1)
  84. end
  85. end
  86. # Handle enabled attribute
  87. 23 if json_data.key?('enabled')
  88. 3 if json_data['enabled'].is_a?(String) && json_data['enabled'].start_with?('@{')
  89. 1 variable = json_data['enabled'].match(/@\{([^}]+)\}/)[1]
  90. 1 code += ",\n" + indent("enabled = data.#{variable}", depth + 1)
  91. else
  92. 2 code += ",\n" + indent("enabled = #{json_data['enabled']}", depth + 1)
  93. end
  94. end
  95. 23 code += "\n" + indent(")", depth)
  96. 23 code
  97. end
  98. 1 private
  99. 1 def self.indent(text, level)
  100. 136 return text if level == 0
  101. 89 spaces = ' ' * level
  102. 89 text.split("\n").map { |line|
  103. 90 line.empty? ? line : spaces + line
  104. }.join("\n")
  105. end
  106. end
  107. end
  108. end
  109. end

lib/compose/components/switch_component.rb

38.97% lines covered

136 relevant lines. 53 lines covered and 83 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. # SwitchComponent handles both Switch (primary) and Toggle (alias) component types
  8. # Switch is the primary component name. Toggle is supported as an alias for backward compatibility.
  9. 1 class SwitchComponent
  10. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  11. # Switch/Toggle uses 'isOn', 'value', 'checked', or 'bind' for binding
  12. # Priority: isOn > value > checked > bind
  13. 7 state_attr = json_data['isOn'] || json_data['value'] || json_data['checked']
  14. 7 checked = if state_attr
  15. if state_attr.is_a?(String) && state_attr.match(/@\{([^}]+)\}/)
  16. variable = $1
  17. "data.#{variable}"
  18. else
  19. # Direct boolean value
  20. state_attr.to_s
  21. end
  22. 7 elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  23. variable = $1
  24. "data.#{variable}"
  25. else
  26. 7 'false'
  27. end
  28. 7 has_label = json_data['labelAttributes']
  29. 7 if has_label
  30. generate_with_label(json_data, depth, required_imports, parent_type, checked)
  31. else
  32. 7 generate_switch_only(json_data, depth, required_imports, parent_type, checked)
  33. end
  34. end
  35. 1 def self.generate_switch_only(json_data, depth, required_imports, parent_type, checked)
  36. 7 code = indent("Switch(", depth)
  37. 7 code += "\n" + indent("checked = #{checked},", depth + 1)
  38. # onCheckedChange handler
  39. 7 binding_variable = nil
  40. 7 state_attr_val = json_data['isOn'] || json_data['value'] || json_data['checked']
  41. 7 if state_attr_val.is_a?(String) && state_attr_val.match(/@\{([^}]+)\}/)
  42. binding_variable = $1
  43. 7 elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  44. binding_variable = $1
  45. end
  46. # onToggle and onValueChange are aliases
  47. # onValueChange (camelCase) -> binding format only (@{functionName})
  48. 7 handler = json_data['onValueChange'] || json_data['onToggle']
  49. 7 if handler
  50. # onValueChange must be binding format
  51. if Helpers::ModifierBuilder.is_binding?(handler)
  52. method_name = Helpers::ModifierBuilder.extract_binding_property(handler)
  53. code += "\n" + indent("onCheckedChange = { viewModel.#{method_name}(it) },", depth + 1)
  54. else
  55. code += "\n" + indent("onCheckedChange = { // ERROR: #{handler} - camelCase events require binding format @{functionName} },", depth + 1)
  56. end
  57. 7 elsif binding_variable
  58. # Update the bound variable
  59. code += "\n" + indent("onCheckedChange = { newValue -> viewModel.updateData(mapOf(\"#{binding_variable}\" to newValue)) },", depth + 1)
  60. else
  61. 7 code += "\n" + indent("onCheckedChange = { },", depth + 1)
  62. end
  63. # Build modifiers
  64. 7 modifiers = []
  65. 7 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  66. 7 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  67. 7 modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
  68. # Add weight modifier if in Row or Column
  69. 7 if parent_type == 'Row' || parent_type == 'Column'
  70. modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
  71. end
  72. 7 code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
  73. # Switch colors
  74. # tint and tintColor are aliases for onTintColor
  75. 7 track_color = json_data['onTintColor'] || json_data['tint'] || json_data['tintColor']
  76. 7 if track_color || json_data['thumbTintColor']
  77. 1 required_imports&.add(:switch_colors)
  78. 1 colors_params = []
  79. 1 if track_color
  80. 1 checkedtrackcolor_resolved = Helpers::ResourceResolver.process_color(track_color, required_imports)
  81. 1 colors_params << "checkedTrackColor = #{checkedtrackcolor_resolved}"
  82. end
  83. 1 if json_data['thumbTintColor']
  84. checkedthumbcolor_resolved = Helpers::ResourceResolver.process_color(json_data['thumbTintColor'], required_imports)
  85. colors_params << "checkedThumbColor = #{checkedthumbcolor_resolved}"
  86. end
  87. 1 if colors_params.any?
  88. 1 code += ",\n" + indent("colors = SwitchDefaults.colors(", depth + 1)
  89. 2 code += "\n" + colors_params.map { |param| indent(param, depth + 2) }.join(",\n")
  90. 1 code += "\n" + indent(")", depth + 1)
  91. end
  92. end
  93. # Handle enabled attribute
  94. 7 if json_data.key?('enabled')
  95. if json_data['enabled'].is_a?(String) && json_data['enabled'].start_with?('@{')
  96. variable = json_data['enabled'].match(/@\{([^}]+)\}/)[1]
  97. code += ",\n" + indent("enabled = data.#{variable}", depth + 1)
  98. else
  99. code += ",\n" + indent("enabled = #{json_data['enabled']}", depth + 1)
  100. end
  101. end
  102. 7 code += "\n" + indent(")", depth)
  103. 7 code
  104. end
  105. 1 def self.generate_with_label(json_data, depth, required_imports, parent_type, checked)
  106. label_attrs = json_data['labelAttributes']
  107. # Row container for label + switch
  108. code = indent("Row(", depth)
  109. code += "\n" + indent("verticalAlignment = Alignment.CenterVertically,", depth + 1)
  110. # Build modifiers for Row
  111. modifiers = []
  112. modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  113. modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  114. if parent_type == 'Row' || parent_type == 'Column'
  115. modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
  116. end
  117. code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
  118. code += "\n" + indent(") {", depth)
  119. # Label Text
  120. label_text = label_attrs['text'] || ''
  121. code += "\n" + indent("Text(", depth + 1)
  122. code += "\n" + indent("text = \"#{label_text}\",", depth + 2)
  123. # Font attributes
  124. if label_attrs['fontSize']
  125. code += "\n" + indent("fontSize = #{label_attrs['fontSize']}.sp,", depth + 2)
  126. end
  127. if label_attrs['fontColor']
  128. font_color = Helpers::ResourceResolver.process_color(label_attrs['fontColor'], required_imports)
  129. code += "\n" + indent("color = #{font_color},", depth + 2)
  130. end
  131. if label_attrs['font']
  132. font_weight = label_attrs['font'].downcase == 'bold' ? 'FontWeight.Bold' : 'FontWeight.Normal'
  133. code += "\n" + indent("fontWeight = #{font_weight},", depth + 2)
  134. end
  135. code += "\n" + indent("modifier = Modifier.weight(1f)", depth + 2)
  136. code += "\n" + indent(")", depth + 1)
  137. # Switch
  138. code += "\n" + indent("Switch(", depth + 1)
  139. code += "\n" + indent("checked = #{checked},", depth + 2)
  140. # onCheckedChange handler
  141. binding_variable = nil
  142. state_attr_val = json_data['isOn'] || json_data['value'] || json_data['checked']
  143. if state_attr_val.is_a?(String) && state_attr_val.match(/@\{([^}]+)\}/)
  144. binding_variable = $1
  145. elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  146. binding_variable = $1
  147. end
  148. handler = json_data['onValueChange'] || json_data['onToggle']
  149. if handler
  150. # onValueChange (camelCase) -> binding format only (@{functionName})
  151. if Helpers::ModifierBuilder.is_binding?(handler)
  152. method_name = Helpers::ModifierBuilder.extract_binding_property(handler)
  153. code += "\n" + indent("onCheckedChange = { viewModel.#{method_name}(it) }", depth + 2)
  154. else
  155. code += "\n" + indent("onCheckedChange = { // ERROR: #{handler} - camelCase events require binding format @{functionName} }", depth + 2)
  156. end
  157. elsif binding_variable
  158. code += "\n" + indent("onCheckedChange = { newValue -> viewModel.updateData(mapOf(\"#{binding_variable}\" to newValue)) }", depth + 2)
  159. else
  160. code += "\n" + indent("onCheckedChange = { }", depth + 2)
  161. end
  162. # Switch colors
  163. track_color = json_data['onTintColor'] || json_data['tint'] || json_data['tintColor']
  164. if track_color || json_data['thumbTintColor']
  165. required_imports&.add(:switch_colors)
  166. colors_params = []
  167. if track_color
  168. checkedtrackcolor_resolved = Helpers::ResourceResolver.process_color(track_color, required_imports)
  169. colors_params << "checkedTrackColor = #{checkedtrackcolor_resolved}"
  170. end
  171. if json_data['thumbTintColor']
  172. checkedthumbcolor_resolved = Helpers::ResourceResolver.process_color(json_data['thumbTintColor'], required_imports)
  173. colors_params << "checkedThumbColor = #{checkedthumbcolor_resolved}"
  174. end
  175. if colors_params.any?
  176. code += ",\n" + indent("colors = SwitchDefaults.colors(", depth + 2)
  177. code += "\n" + colors_params.map { |param| indent(param, depth + 3) }.join(",\n")
  178. code += "\n" + indent(")", depth + 2)
  179. end
  180. end
  181. # Handle enabled attribute
  182. if json_data.key?('enabled')
  183. if json_data['enabled'].is_a?(String) && json_data['enabled'].start_with?('@{')
  184. variable = json_data['enabled'].match(/@\{([^}]+)\}/)[1]
  185. code += ",\n" + indent("enabled = data.#{variable}", depth + 2)
  186. else
  187. code += ",\n" + indent("enabled = #{json_data['enabled']}", depth + 2)
  188. end
  189. end
  190. code += "\n" + indent(")", depth + 1)
  191. code += "\n" + indent("}", depth)
  192. code
  193. end
  194. 1 private
  195. 1 def self.indent(text, level)
  196. 31 return text if level == 0
  197. 17 spaces = ' ' * level
  198. 17 text.split("\n").map { |line|
  199. 17 line.empty? ? line : spaces + line
  200. }.join("\n")
  201. end
  202. end
  203. end
  204. end
  205. end

lib/compose/components/table_component.rb

100.0% lines covered

109 relevant lines. 109 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 module KjuiTools
  4. 1 module Compose
  5. 1 module Components
  6. 1 class TableComponent
  7. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  8. 21 required_imports&.add(:lazy_column)
  9. # Table uses data binding for items
  10. 21 items = if json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  11. 1 variable = $1
  12. 1 "data.#{variable}"
  13. 20 elsif json_data['items'] && json_data['items'].match(/@\{([^}]+)\}/)
  14. 1 variable = $1
  15. 1 "data.#{variable}"
  16. else
  17. 19 'emptyList()'
  18. end
  19. 21 code = indent("LazyColumn(", depth)
  20. # Content padding
  21. 21 if json_data['contentPadding']
  22. 2 padding = json_data['contentPadding']
  23. 2 if padding.is_a?(Array) && padding.length == 4
  24. 1 code += "\n" + indent("contentPadding = PaddingValues(top = #{padding[0]}.dp, end = #{padding[1]}.dp, bottom = #{padding[2]}.dp, start = #{padding[3]}.dp),", depth + 1)
  25. 1 elsif padding.is_a?(Numeric)
  26. 1 code += "\n" + indent("contentPadding = PaddingValues(#{padding}.dp),", depth + 1)
  27. end
  28. end
  29. # Vertical arrangement (spacing between rows)
  30. 21 if json_data['rowSpacing'] || json_data['spacing']
  31. 2 required_imports&.add(:arrangement)
  32. 2 spacing = json_data['rowSpacing'] || json_data['spacing'] || 0
  33. 2 code += "\n" + indent("verticalArrangement = Arrangement.spacedBy(#{spacing}.dp),", depth + 1)
  34. end
  35. # Build modifiers
  36. 21 modifiers = []
  37. 21 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  38. 21 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  39. 21 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  40. 21 modifiers.concat(Helpers::ModifierBuilder.build_background(json_data, required_imports))
  41. 21 modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
  42. 21 code += Helpers::ModifierBuilder.format(modifiers, depth)
  43. 21 code += "\n" + indent(") {", depth)
  44. # Table header if specified
  45. 21 if json_data['header']
  46. 4 code += "\n" + indent("item {", depth + 1)
  47. 4 code += generate_header_row(json_data['header'], depth + 2, required_imports)
  48. 4 code += "\n" + indent("}", depth + 1)
  49. # Divider after header
  50. 4 if json_data['separatorStyle'] != 'none'
  51. 3 code += "\n" + indent("item {", depth + 1)
  52. 3 code += "\n" + indent("Divider(", depth + 2)
  53. 3 code += "\n" + indent("color = Color.LightGray,", depth + 3)
  54. 3 code += "\n" + indent("thickness = 1.dp", depth + 3)
  55. 3 code += "\n" + indent(")", depth + 2)
  56. 3 code += "\n" + indent("}", depth + 1)
  57. end
  58. end
  59. # Table rows
  60. 21 code += "\n" + indent("items(#{items}) { item ->", depth + 1)
  61. # Row content
  62. 21 if json_data['cell']
  63. # Custom cell template
  64. 1 cell_content = generate_table_cell(json_data['cell'], depth + 2, required_imports)
  65. 1 code += "\n" + cell_content
  66. else
  67. # Default row
  68. 20 code += generate_default_row(json_data, depth + 2, required_imports)
  69. end
  70. # Separator between rows
  71. 21 if json_data['separatorStyle'] != 'none'
  72. 19 code += "\n" + indent("Divider(", depth + 2)
  73. # Separator inset
  74. 19 if json_data['separatorInset']
  75. 2 inset = json_data['separatorInset']
  76. 2 if inset.is_a?(Hash)
  77. 2 start_padding = inset['left'] || inset['start'] || 0
  78. 2 code += "\n" + indent("modifier = Modifier.padding(start = #{start_padding}.dp),", depth + 3)
  79. end
  80. end
  81. 19 code += "\n" + indent("color = Color.LightGray,", depth + 3)
  82. 19 code += "\n" + indent("thickness = 0.5.dp", depth + 3)
  83. 19 code += "\n" + indent(")", depth + 2)
  84. end
  85. 21 code += "\n" + indent("}", depth + 1)
  86. 21 code += "\n" + indent("}", depth)
  87. 21 code
  88. end
  89. 1 private
  90. 1 def self.generate_header_row(header_data, depth, required_imports)
  91. 4 code = indent("Row(", depth)
  92. 4 code += "\n" + indent("modifier = Modifier", depth + 1)
  93. 4 code += "\n" + indent(" .fillMaxWidth()", depth + 1)
  94. 4 code += "\n" + indent(" .padding(horizontal = 16.dp, vertical = 12.dp),", depth + 1)
  95. 4 code += "\n" + indent("horizontalArrangement = Arrangement.SpaceBetween", depth + 1)
  96. 4 code += "\n" + indent(") {", depth)
  97. 4 if header_data.is_a?(Array)
  98. 3 header_data.each do |column|
  99. 5 code += "\n" + indent("Text(", depth + 1)
  100. 5 code += "\n" + indent("text = \"#{column}\",", depth + 2)
  101. 5 code += "\n" + indent("fontWeight = FontWeight.Bold,", depth + 2)
  102. 5 code += "\n" + indent("modifier = Modifier.weight(1f)", depth + 2)
  103. 5 code += "\n" + indent(")", depth + 1)
  104. end
  105. else
  106. 1 code += "\n" + indent("Text(text = \"Header\", fontWeight = FontWeight.Bold)", depth + 1)
  107. end
  108. 4 code += "\n" + indent("}", depth)
  109. 4 code
  110. end
  111. 1 def self.generate_table_cell(cell_data, depth, required_imports)
  112. 1 code = indent("Row(", depth)
  113. 1 code += "\n" + indent("modifier = Modifier", depth + 1)
  114. 1 code += "\n" + indent(" .fillMaxWidth()", depth + 1)
  115. 1 code += "\n" + indent(" .clickable { /* Handle row click */ }", depth + 1)
  116. 1 code += "\n" + indent(" .padding(horizontal = 16.dp, vertical = 12.dp)", depth + 1)
  117. 1 code += "\n" + indent(") {", depth)
  118. # Cell content based on template
  119. 1 code += "\n" + indent("// Custom cell rendering", depth + 1)
  120. 1 code += "\n" + indent("Text(text = item.toString())", depth + 1)
  121. 1 code += "\n" + indent("}", depth)
  122. 1 code
  123. end
  124. 1 def self.generate_default_row(json_data, depth, required_imports)
  125. 20 row_height = json_data['rowHeight'] || 60
  126. 20 code = "\n" + indent("Row(", depth)
  127. 20 code += "\n" + indent("modifier = Modifier", depth + 1)
  128. 20 code += "\n" + indent(" .fillMaxWidth()", depth + 1)
  129. 20 code += "\n" + indent(" .height(#{row_height}.dp)", depth + 1)
  130. 20 code += "\n" + indent(" .clickable { /* Handle row click */ }", depth + 1)
  131. 20 code += "\n" + indent(" .padding(horizontal = 16.dp),", depth + 1)
  132. 20 code += "\n" + indent("verticalAlignment = Alignment.CenterVertically", depth + 1)
  133. 20 code += "\n" + indent(") {", depth)
  134. 20 code += "\n" + indent("Text(text = item.toString())", depth + 1)
  135. 20 code += "\n" + indent("}", depth)
  136. 20 code
  137. end
  138. 1 def self.indent(text, level)
  139. 479 return text if level == 0
  140. 415 spaces = ' ' * level
  141. 415 text.split("\n").map { |line|
  142. 417 line.empty? ? line : spaces + line
  143. }.join("\n")
  144. end
  145. end
  146. end
  147. end
  148. end

lib/compose/components/tabview_component.rb

87.39% lines covered

119 relevant lines. 104 lines covered and 15 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class TabviewComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. # TabView maps to NavigationBar with NavigationBarItem in Compose (Material 3)
  10. 16 required_imports&.add(:navigation_bar)
  11. 16 required_imports&.add(:remember_state)
  12. 16 required_imports&.add(:scaffold)
  13. 16 required_imports&.add(:painter_resource)
  14. 16 required_imports&.add(:r_class)
  15. 16 required_imports&.add(:safe_area_config)
  16. 16 required_imports&.add(:composition_local_provider)
  17. 16 tabs = json_data['tabs'] || []
  18. # Add imports for tab views
  19. 16 tabs.each do |tab|
  20. 22 if tab['view']
  21. 3 pascal_name = tab['view'].split('_').map(&:capitalize).join
  22. 3 required_imports&.add("tabview:#{pascal_name}")
  23. end
  24. end
  25. # Generate state variable for selected tab
  26. 16 state_var = "selectedTab"
  27. 16 selected_binding = json_data['selectedIndex']
  28. 16 code = indent("// TabView with NavigationBar", depth)
  29. # If there's a binding, use it; otherwise create local state
  30. 16 if selected_binding && selected_binding.is_a?(String) && selected_binding.start_with?('@{')
  31. 1 binding_prop = selected_binding.gsub(/@\{|\}/, '')
  32. 1 state_expr = "data.#{binding_prop}"
  33. 1 setter_expr = "viewModel.updateData(mapOf(\"#{binding_prop}\" to it))"
  34. else
  35. 15 code += "\n" + indent("var #{state_var} by remember { mutableStateOf(0) }", depth)
  36. 15 state_expr = state_var
  37. 15 setter_expr = "#{state_var} = it"
  38. end
  39. 16 code += "\n\n" + indent("Scaffold(", depth)
  40. 16 code += "\n" + indent("bottomBar = {", depth + 1)
  41. # NavigationBar
  42. 16 code += "\n" + indent("NavigationBar(", depth + 2)
  43. # Tab bar background color
  44. 16 if json_data['tabBarBackground']
  45. 1 bg_color = Helpers::ResourceResolver.process_color(json_data['tabBarBackground'], required_imports)
  46. 1 code += "\n" + indent("containerColor = #{bg_color},", depth + 3)
  47. end
  48. 16 code += "\n" + indent(") {", depth + 2)
  49. # Generate NavigationBarItem for each tab
  50. 16 tabs.each_with_index do |tab, index|
  51. 22 title = tab['title'] || "Tab #{index + 1}"
  52. 22 icon = tab['icon'] || 'circle'
  53. 22 selected_icon = tab['selectedIcon'] || icon
  54. 22 icon_type = tab['iconType'] || 'system'
  55. 22 code += "\n" + indent("NavigationBarItem(", depth + 3)
  56. 22 code += "\n" + indent("selected = #{state_expr} == #{index},", depth + 4)
  57. 22 code += "\n" + indent("onClick = { #{setter_expr.gsub('it', index.to_s)} },", depth + 4)
  58. # Icon - handle iconType for system vs resource
  59. 22 code += "\n" + indent("icon = {", depth + 4)
  60. 22 if icon_type == 'resource'
  61. # Use drawable resource
  62. if icon != selected_icon
  63. # Different icons for selected/unselected
  64. code += "\n" + indent("Icon(", depth + 5)
  65. code += "\n" + indent("painter = if (#{state_expr} == #{index}) painterResource(R.drawable.#{selected_icon}) else painterResource(R.drawable.#{icon}),", depth + 6)
  66. code += "\n" + indent("contentDescription = \"#{title}\"", depth + 6)
  67. code += "\n" + indent(")", depth + 5)
  68. else
  69. code += "\n" + indent("Icon(", depth + 5)
  70. code += "\n" + indent("painter = painterResource(R.drawable.#{icon}),", depth + 6)
  71. code += "\n" + indent("contentDescription = \"#{title}\"", depth + 6)
  72. code += "\n" + indent(")", depth + 5)
  73. end
  74. else
  75. # Use Material Icons (system)
  76. 22 required_imports&.add(:material_icons)
  77. 22 material_icon = to_icon_name(icon)
  78. 22 material_selected_icon = to_icon_name(selected_icon)
  79. 22 if icon != selected_icon
  80. 1 code += "\n" + indent("Icon(", depth + 5)
  81. 1 code += "\n" + indent("imageVector = if (#{state_expr} == #{index}) Icons.Filled.#{material_selected_icon} else Icons.Outlined.#{material_icon},", depth + 6)
  82. 1 code += "\n" + indent("contentDescription = \"#{title}\"", depth + 6)
  83. 1 code += "\n" + indent(")", depth + 5)
  84. else
  85. 21 code += "\n" + indent("Icon(", depth + 5)
  86. 21 code += "\n" + indent("imageVector = Icons.Filled.#{material_icon},", depth + 6)
  87. 21 code += "\n" + indent("contentDescription = \"#{title}\"", depth + 6)
  88. 21 code += "\n" + indent(")", depth + 5)
  89. end
  90. end
  91. 22 code += "\n" + indent("},", depth + 4)
  92. # Label (show/hide based on showLabels)
  93. 22 show_labels = json_data['showLabels'] != false
  94. 22 if show_labels
  95. 21 code += "\n" + indent("label = { Text(\"#{title}\") },", depth + 4)
  96. end
  97. # Tint colors
  98. 22 if json_data['tintColor'] || json_data['unselectedColor']
  99. 1 tint = json_data['tintColor'] ? Helpers::ResourceResolver.process_color(json_data['tintColor'], required_imports) : 'MaterialTheme.colorScheme.primary'
  100. 1 unselected = json_data['unselectedColor'] ? Helpers::ResourceResolver.process_color(json_data['unselectedColor'], required_imports) : 'MaterialTheme.colorScheme.onSurfaceVariant'
  101. 1 code += "\n" + indent("colors = NavigationBarItemDefaults.colors(", depth + 4)
  102. 1 code += "\n" + indent("selectedIconColor = #{tint},", depth + 5)
  103. 1 code += "\n" + indent("selectedTextColor = #{tint},", depth + 5)
  104. 1 code += "\n" + indent("unselectedIconColor = #{unselected},", depth + 5)
  105. 1 code += "\n" + indent("unselectedTextColor = #{unselected}", depth + 5)
  106. 1 code += "\n" + indent(")", depth + 4)
  107. end
  108. # Badge
  109. 22 if tab['badge']
  110. badge_value = tab['badge']
  111. if badge_value.is_a?(String) && badge_value.start_with?('@{')
  112. binding_prop = badge_value.gsub(/@\{|\}/, '')
  113. code = code.gsub(/icon = \{/, "icon = {\n#{indent('BadgedBox(badge = { Badge { Text(\"${data.' + binding_prop + '}\") } }) {', depth + 5)}")
  114. elsif badge_value.is_a?(Integer) && badge_value > 0
  115. code = code.gsub(/icon = \{/, "icon = {\n#{indent("BadgedBox(badge = { Badge { Text(\"#{badge_value}\") } }) {", depth + 5)}")
  116. end
  117. end
  118. 22 code += "\n" + indent(")", depth + 3)
  119. end
  120. 16 code += "\n" + indent("}", depth + 2)
  121. 16 code += "\n" + indent("}", depth + 1)
  122. 16 code += "\n" + indent(") { innerPadding ->", depth)
  123. # Tab content using when expression
  124. # Only apply bottom padding for NavigationBar - child views handle their own top safe area
  125. 16 if tabs.any?
  126. 15 code += "\n" + indent("Box(modifier = Modifier.padding(bottom = innerPadding.calculateBottomPadding())) {", depth + 1)
  127. # Provide SafeAreaConfig to tell child views to ignore bottom safe area
  128. 15 code += "\n" + indent("CompositionLocalProvider(", depth + 2)
  129. 15 code += "\n" + indent("LocalSafeAreaConfig provides SafeAreaConfig(ignoreBottom = true)", depth + 3)
  130. 15 code += "\n" + indent(") {", depth + 2)
  131. 15 code += "\n" + indent("when (#{state_expr}) {", depth + 3)
  132. 15 tabs.each_with_index do |tab, index|
  133. 22 code += "\n" + indent("#{index} -> {", depth + 4)
  134. # Content for each tab - reference view by name
  135. 22 view_name = tab['view']
  136. 22 if view_name
  137. # Convert snake_case to PascalCase for Kotlin class name
  138. 3 pascal_name = view_name.split('_').map(&:capitalize).join
  139. 3 code += "\n" + indent("#{pascal_name}View()", depth + 5)
  140. else
  141. 19 code += "\n" + indent("Text(\"#{tab['title'] || "Tab #{index + 1}"} content\")", depth + 5)
  142. end
  143. 22 code += "\n" + indent("}", depth + 4)
  144. end
  145. 15 code += "\n" + indent("}", depth + 3)
  146. 15 code += "\n" + indent("}", depth + 2)
  147. 15 code += "\n" + indent("}", depth + 1)
  148. end
  149. 16 code += "\n" + indent("}", depth)
  150. 16 code
  151. end
  152. 1 private
  153. 1 def self.indent(text, level)
  154. 596 return text if level == 0
  155. 516 spaces = ' ' * level
  156. 516 text.split("\n").map { |line|
  157. 516 line.empty? ? line : spaces + line
  158. }.join("\n")
  159. end
  160. # Convert icon name to Material Icons format
  161. # e.g., "house" -> "Home", "person" -> "Person"
  162. 1 def self.to_icon_name(icon)
  163. # Map common SF Symbol names to Material Icons
  164. 48 icon_map = {
  165. 'house' => 'Home',
  166. 'house.fill' => 'Home',
  167. 'person' => 'Person',
  168. 'person.fill' => 'Person',
  169. 'gearshape' => 'Settings',
  170. 'gearshape.fill' => 'Settings',
  171. 'gear' => 'Settings',
  172. 'magnifyingglass' => 'Search',
  173. 'heart' => 'Favorite',
  174. 'heart.fill' => 'Favorite',
  175. 'star' => 'Star',
  176. 'star.fill' => 'Star',
  177. 'bell' => 'Notifications',
  178. 'bell.fill' => 'Notifications',
  179. 'cart' => 'ShoppingCart',
  180. 'cart.fill' => 'ShoppingCart',
  181. 'list.bullet' => 'List',
  182. 'square.grid.2x2' => 'GridView',
  183. 'circle' => 'Circle'
  184. }
  185. 48 icon_map[icon] || icon.split('.').first.capitalize
  186. end
  187. end
  188. end
  189. end
  190. end

lib/compose/components/text_component.rb

68.96% lines covered

335 relevant lines. 231 lines covered and 104 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/visibility_helper'
  4. 1 require_relative '../helpers/resource_resolver'
  5. 1 module KjuiTools
  6. 1 module Compose
  7. 1 module Components
  8. # Text Component Generator
  9. #
  10. # NOTE: Label is the primary component name in JsonUI.
  11. # Text is supported as an alias for backward compatibility.
  12. # Both "type": "Label" and "type": "Text" work identically.
  13. #
  14. 1 class TextComponent
  15. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  16. # Check if component should be skipped entirely (static gone/hidden)
  17. 62 return "" if Helpers::VisibilityHelper.should_skip_render?(json_data)
  18. # Check if we need to use PartialAttributesText for partial attributes
  19. 61 if json_data['partialAttributes'] && json_data['partialAttributes'].any?
  20. 8 return generate_with_partial_attributes_component(json_data, depth, required_imports, parent_type)
  21. end
  22. # Check if we need to use PartialAttributesText for linkable attribute
  23. 53 if json_data['linkable']
  24. 9 return generate_with_partial_attributes_for_linkable(json_data, depth, required_imports, parent_type)
  25. end
  26. 44 text = Helpers::ResourceResolver.process_text(json_data['text'] || '', required_imports)
  27. 44 component_code = indent("Text(", depth)
  28. 44 component_code += "\n" + indent("text = #{text},", depth + 1)
  29. # Font size
  30. 44 if json_data['fontSize']
  31. 2 component_code += "\n" + indent("fontSize = #{json_data['fontSize']}.sp,", depth + 1)
  32. end
  33. # Font color (official attribute)
  34. 44 if json_data['fontColor']
  35. 1 color_value = Helpers::ResourceResolver.process_color(json_data['fontColor'], required_imports)
  36. 1 component_code += "\n" + indent("color = #{color_value},", depth + 1) if color_value
  37. end
  38. # Font weight values that should use system font weight
  39. 44 weight_names = ['bold', 'semibold', 'medium', 'light', 'thin', 'extralight', 'heavy', 'black', 'normal']
  40. 44 weight_mapping = {
  41. 'thin' => 'Thin',
  42. 'extralight' => 'ExtraLight',
  43. 'light' => 'Light',
  44. 'normal' => 'Normal',
  45. 'medium' => 'Medium',
  46. 'semibold' => 'SemiBold',
  47. 'bold' => 'Bold',
  48. 'extrabold' => 'ExtraBold',
  49. 'heavy' => 'ExtraBold',
  50. 'black' => 'Black'
  51. }
  52. # Handle font attribute - can be weight name or custom font family
  53. 44 if json_data['font']
  54. 4 font_value = json_data['font'].to_s.downcase
  55. 4 if weight_names.include?(font_value)
  56. # It's a weight name, use FontWeight
  57. 3 weight = weight_mapping[font_value] || 'Normal'
  58. 3 required_imports&.add(:font_weight)
  59. 3 component_code += "\n" + indent("fontWeight = FontWeight.#{weight},", depth + 1)
  60. else
  61. # It's a custom font family name
  62. 1 required_imports&.add(:font_family)
  63. 1 component_code += "\n" + indent("fontFamily = FontFamily(Font(R.font.#{json_data['font'].gsub('-', '_').downcase})),", depth + 1)
  64. end
  65. 40 elsif json_data['fontWeight']
  66. # fontWeight attribute takes precedence if font not specified
  67. 7 weight = weight_mapping[json_data['fontWeight'].downcase] || json_data['fontWeight'].capitalize
  68. 7 required_imports&.add(:font_weight)
  69. 7 component_code += "\n" + indent("fontWeight = FontWeight.#{weight},", depth + 1)
  70. end
  71. # Text decoration (underline, strikethrough)
  72. 44 text_decorations = []
  73. 44 if json_data['underline']
  74. 2 required_imports&.add(:text_decoration)
  75. 2 text_decorations << "TextDecoration.Underline"
  76. end
  77. 44 if json_data['strikethrough']
  78. 2 required_imports&.add(:text_decoration)
  79. 2 text_decorations << "TextDecoration.LineThrough"
  80. end
  81. 44 if text_decorations.any?
  82. 3 if text_decorations.length > 1
  83. 1 component_code += "\n" + indent("textDecoration = TextDecoration.combine(listOf(#{text_decorations.join(', ')})),", depth + 1)
  84. else
  85. 2 component_code += "\n" + indent("textDecoration = #{text_decorations.first},", depth + 1)
  86. end
  87. end
  88. # Text shadow and line height
  89. 44 style_parts = []
  90. 44 if json_data['textShadow']
  91. 1 required_imports&.add(:shadow_style)
  92. 1 style_parts << "shadow = Shadow(color = Color.Black, offset = Offset(2f, 2f), blurRadius = 4f)"
  93. end
  94. 44 if json_data['lineHeightMultiple']
  95. 1 required_imports&.add(:text_style)
  96. # Line height multiplier - apply to font size
  97. 1 line_height = json_data['fontSize'] ? json_data['fontSize'].to_f * json_data['lineHeightMultiple'].to_f : 14.0 * json_data['lineHeightMultiple'].to_f
  98. 1 style_parts << "lineHeight = #{line_height}.sp"
  99. 43 elsif json_data['lineSpacing']
  100. required_imports&.add(:text_style)
  101. # Line spacing - add to base font size
  102. base_size = json_data['fontSize'] ? json_data['fontSize'].to_f : 14.0
  103. line_height = base_size + json_data['lineSpacing'].to_f
  104. style_parts << "lineHeight = #{line_height}.sp"
  105. 43 elsif json_data['fontSize']
  106. # Default: set lineHeight = fontSize to match iOS default behavior
  107. 1 required_imports&.add(:text_style)
  108. 1 style_parts << "lineHeight = #{json_data['fontSize']}.sp"
  109. end
  110. 44 if style_parts.any?
  111. 3 required_imports&.add(:text_style)
  112. 3 component_code += "\n" + indent("style = TextStyle(#{style_parts.join(', ')}),", depth + 1)
  113. end
  114. # Build modifiers
  115. 44 modifiers = []
  116. # Get visibility info (but don't add to modifiers, will be handled by wrapper)
  117. 44 visibility_result = Helpers::ModifierBuilder.build_visibility(json_data, required_imports)
  118. 44 modifiers.concat(visibility_result[:modifiers]) if visibility_result[:modifiers].any?
  119. 44 modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
  120. # Add weight modifier if in Row or Column
  121. 44 if parent_type == 'Row' || parent_type == 'Column'
  122. 2 modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
  123. end
  124. # 1. Add size first (total size including padding)
  125. 44 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  126. # 2. Add margins (outside spacing)
  127. 44 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  128. # 3. Add shadow before background
  129. 44 modifiers.concat(Helpers::ModifierBuilder.build_shadow(json_data, required_imports))
  130. # 4. Add background before padding (so padding creates space inside the background)
  131. 44 modifiers.concat(Helpers::ModifierBuilder.build_background(json_data, required_imports))
  132. # 5. Handle edgeInset for text-specific padding (inside spacing) - applied last
  133. 44 if json_data['edgeInset']
  134. 2 insets = json_data['edgeInset']
  135. 2 if insets.is_a?(Array) && insets.length == 4
  136. 1 modifiers << ".padding(top = #{insets[0]}.dp, end = #{insets[1]}.dp, bottom = #{insets[2]}.dp, start = #{insets[3]}.dp)"
  137. 1 elsif insets.is_a?(Numeric)
  138. 1 modifiers << ".padding(#{insets}.dp)"
  139. end
  140. else
  141. 42 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  142. end
  143. # Format modifiers
  144. 44 if modifiers.any?
  145. 8 component_code += Helpers::ModifierBuilder.format(modifiers, depth)
  146. else
  147. 36 component_code += "\n" + indent("modifier = Modifier", depth + 1)
  148. end
  149. # Text alignment
  150. 44 if json_data['textAlign']
  151. 3 required_imports&.add(:text_align)
  152. 3 case json_data['textAlign'].downcase
  153. when 'center'
  154. 1 component_code += ",\n" + indent("textAlign = TextAlign.Center", depth + 1)
  155. when 'right'
  156. 1 component_code += ",\n" + indent("textAlign = TextAlign.End", depth + 1)
  157. when 'left'
  158. 1 component_code += ",\n" + indent("textAlign = TextAlign.Start", depth + 1)
  159. end
  160. 41 elsif json_data['centerHorizontal']
  161. 1 required_imports&.add(:text_align)
  162. 1 component_code += ",\n" + indent("textAlign = TextAlign.Center", depth + 1)
  163. end
  164. # Lines (maxLines)
  165. 44 if json_data['lines']
  166. 2 if json_data['lines'] == 0
  167. 1 component_code += ",\n" + indent("maxLines = Int.MAX_VALUE", depth + 1)
  168. else
  169. 1 component_code += ",\n" + indent("maxLines = #{json_data['lines']}", depth + 1)
  170. end
  171. end
  172. # Auto shrink text
  173. 44 if json_data['autoShrink']
  174. required_imports&.add(:text_overflow)
  175. component_code += ",\n" + indent("softWrap = false", depth + 1)
  176. component_code += ",\n" + indent("maxLines = 1", depth + 1)
  177. component_code += ",\n" + indent("overflow = TextOverflow.Ellipsis", depth + 1)
  178. end
  179. # Minimum scale factor (auto-shrink text)
  180. # In Compose, this is achieved with softWrap=false and overflow=Visible to allow text to scale
  181. 44 if json_data['minimumScaleFactor']
  182. # Note: Compose doesn't have direct equivalent, but we can use single line with ellipsis
  183. # or recommend using a custom composable. For now, we'll add a comment
  184. 1 component_code += ",\n" + indent("// minimumScaleFactor: #{json_data['minimumScaleFactor']} - Consider using AutoSizeText library", depth + 1)
  185. 1 component_code += ",\n" + indent("maxLines = 1", depth + 1)
  186. 1 required_imports&.add(:text_overflow)
  187. 1 component_code += ",\n" + indent("overflow = TextOverflow.Ellipsis", depth + 1)
  188. end
  189. # Line break mode (overflow)
  190. 44 if json_data['lineBreakMode']
  191. 3 required_imports&.add(:text_overflow)
  192. 3 case json_data['lineBreakMode'].downcase
  193. when 'clip'
  194. 1 component_code += ",\n" + indent("overflow = TextOverflow.Clip", depth + 1)
  195. when 'tail', 'word'
  196. 2 component_code += ",\n" + indent("overflow = TextOverflow.Ellipsis", depth + 1)
  197. end
  198. end
  199. # highlightColor - color when pressed/selected
  200. 44 if json_data['highlightColor']
  201. highlight_color = Helpers::ResourceResolver.process_color(json_data['highlightColor'], required_imports)
  202. component_code += ",\n" + indent("// highlightColor: #{highlight_color} - Use InteractionSource for pressed state styling", depth + 1)
  203. end
  204. 44 component_code += "\n" + indent(")", depth)
  205. # Wrap with VisibilityWrapper if needed
  206. 44 Helpers::VisibilityHelper.wrap_with_visibility(json_data, component_code, depth, required_imports)
  207. end
  208. 1 private
  209. 1 def self.generate_with_partial_attributes_for_linkable(json_data, depth, required_imports, parent_type)
  210. 9 required_imports&.add(:partial_attributes_text)
  211. 9 text = json_data['text'] || ''
  212. 9 code = indent("PartialAttributesText(", depth)
  213. 9 code += "\n" + indent("text = \"#{escape_string(text)}\",", depth + 1)
  214. 9 code += "\n" + indent("linkable = true,", depth + 1)
  215. # Build style
  216. 9 style_parts = []
  217. 9 if json_data['fontSize']
  218. 1 style_parts << "fontSize = #{json_data['fontSize']}.sp"
  219. end
  220. 9 if json_data['fontColor']
  221. 1 color_value = Helpers::ResourceResolver.process_color(json_data['fontColor'], required_imports)
  222. 1 style_parts << "color = #{color_value}" if color_value
  223. end
  224. # Handle font attribute for linkable text style
  225. 9 font_weight_result = resolve_font_attribute(json_data, required_imports)
  226. 9 style_parts << font_weight_result if font_weight_result
  227. 9 if json_data['textAlign']
  228. 3 required_imports&.add(:text_align)
  229. 3 case json_data['textAlign'].downcase
  230. when 'center'
  231. 1 style_parts << "textAlign = TextAlign.Center"
  232. when 'right'
  233. 1 style_parts << "textAlign = TextAlign.End"
  234. when 'left'
  235. 1 style_parts << "textAlign = TextAlign.Start"
  236. end
  237. end
  238. 9 if style_parts.any?
  239. 6 required_imports&.add(:text_style)
  240. 6 code += "\n" + indent("style = TextStyle(#{style_parts.join(', ')}),", depth + 1)
  241. end
  242. # Build modifiers
  243. 9 modifiers = []
  244. 9 modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
  245. 9 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  246. # Handle edgeInset for text-specific padding
  247. 9 if json_data['edgeInset']
  248. 2 insets = json_data['edgeInset']
  249. 2 if insets.is_a?(Array) && insets.length == 4
  250. 1 modifiers << ".padding(top = #{insets[0]}.dp, end = #{insets[1]}.dp, bottom = #{insets[2]}.dp, start = #{insets[3]}.dp)"
  251. 1 elsif insets.is_a?(Numeric)
  252. 1 modifiers << ".padding(#{insets}.dp)"
  253. end
  254. else
  255. 7 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  256. end
  257. # Add background
  258. 9 modifiers.concat(Helpers::ModifierBuilder.build_background(json_data, required_imports))
  259. 9 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  260. 9 if modifiers.any?
  261. 2 code += Helpers::ModifierBuilder.format(modifiers, depth)
  262. else
  263. 7 code += "\n" + indent("modifier = Modifier", depth + 1)
  264. end
  265. 9 code += "\n" + indent(")", depth)
  266. # Wrap with VisibilityWrapper if needed
  267. 9 Helpers::VisibilityHelper.wrap_with_visibility(json_data, code, depth, required_imports)
  268. end
  269. 1 def self.generate_with_partial_attributes_component(json_data, depth, required_imports, parent_type)
  270. 8 required_imports&.add(:partial_attributes_text)
  271. 8 text = json_data['text'] || ''
  272. 8 partial_attrs = json_data['partialAttributes']
  273. 8 code = indent("PartialAttributesText(", depth)
  274. 8 code += "\n" + indent("text = \"#{escape_string(text)}\",", depth + 1)
  275. # Build partial attributes list
  276. 8 code += "\n" + indent("partialAttributes = listOf(", depth + 1)
  277. 8 partial_attrs.each_with_index do |attr, index|
  278. 9 code += "\n" + indent("PartialAttribute.fromJsonRange(", depth + 2)
  279. # Handle range - can be array or string
  280. 9 range = attr['range']
  281. 9 if range.is_a?(Array)
  282. 8 code += "\n" + indent("range = listOf(#{range.join(', ')}),", depth + 3)
  283. 1 elsif range.is_a?(String)
  284. 1 code += "\n" + indent("range = \"#{escape_string(range)}\",", depth + 3)
  285. end
  286. 9 code += "\n" + indent("text = \"#{escape_string(text)}\",", depth + 3)
  287. # Add optional attributes
  288. 9 if attr['fontColor']
  289. 4 code += "\n" + indent("fontColor = \"#{attr['fontColor']}\",", depth + 3)
  290. end
  291. 9 if attr['fontSize']
  292. 1 code += "\n" + indent("fontSize = #{attr['fontSize']},", depth + 3)
  293. end
  294. 9 if attr['fontWeight']
  295. 1 code += "\n" + indent("fontWeight = \"#{attr['fontWeight']}\",", depth + 3)
  296. end
  297. 9 if attr['background']
  298. 1 code += "\n" + indent("background = \"#{attr['background']}\",", depth + 3)
  299. end
  300. 9 if attr['underline']
  301. 1 code += "\n" + indent("underline = #{attr['underline']},", depth + 3)
  302. end
  303. 9 if attr['strikethrough']
  304. 1 code += "\n" + indent("strikethrough = #{attr['strikethrough']},", depth + 3)
  305. end
  306. # Handle click events for partial attributes
  307. # onclick (lowercase) -> selector format (string only)
  308. # onClick (camelCase) -> binding format only (@{functionName})
  309. 9 if attr['onclick']
  310. 1 handler_call = Helpers::ModifierBuilder.get_event_handler_call(attr['onclick'], is_camel_case: false)
  311. 1 code += "\n" + indent("onClick = { #{handler_call} }", depth + 3)
  312. 8 elsif attr['onClick']
  313. handler_call = Helpers::ModifierBuilder.get_event_handler_call(attr['onClick'], is_camel_case: true)
  314. code += "\n" + indent("onClick = { #{handler_call} }", depth + 3)
  315. else
  316. 8 code += "\n" + indent("onClick = null", depth + 3)
  317. end
  318. 9 code += "\n" + indent(")!!", depth + 2) # !! because fromJsonRange returns nullable
  319. 9 code += "," if index < partial_attrs.length - 1
  320. end
  321. 8 code += "\n" + indent("),", depth + 1)
  322. # Build modifiers
  323. 8 modifiers = []
  324. 8 modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
  325. 8 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  326. 8 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  327. 8 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  328. 8 if modifiers.any?
  329. code += Helpers::ModifierBuilder.format(modifiers, depth)
  330. else
  331. 8 code += "\n" + indent("modifier = Modifier", depth + 1)
  332. end
  333. # Add style
  334. 8 style_parts = []
  335. 8 style_parts << "fontSize = #{json_data['fontSize']}.sp" if json_data['fontSize']
  336. 8 if json_data['fontColor']
  337. color_value = Helpers::ResourceResolver.process_color(json_data['fontColor'], required_imports)
  338. style_parts << "color = #{color_value}" if color_value
  339. end
  340. 8 if json_data['textAlign']
  341. required_imports&.add(:text_align)
  342. case json_data['textAlign'].downcase
  343. when 'center'
  344. style_parts << "textAlign = TextAlign.Center"
  345. when 'right'
  346. style_parts << "textAlign = TextAlign.End"
  347. when 'left'
  348. style_parts << "textAlign = TextAlign.Start"
  349. end
  350. end
  351. 8 if style_parts.any?
  352. required_imports&.add(:text_style)
  353. code += ",\n" + indent("style = TextStyle(#{style_parts.join(', ')})", depth + 1)
  354. end
  355. 8 code += "\n" + indent(")", depth)
  356. # Wrap with VisibilityWrapper if needed
  357. 8 Helpers::VisibilityHelper.wrap_with_visibility(json_data, code, depth, required_imports)
  358. end
  359. 1 def self.generate_with_partial_attributes(json_data, depth, required_imports, parent_type)
  360. required_imports&.add(:annotated_string)
  361. required_imports&.add(:clickable_text)
  362. required_imports&.add(:remember_state)
  363. text = json_data['text'] || ''
  364. partial_attrs = json_data['partialAttributes']
  365. # Build AnnotatedString as a variable first
  366. code = indent("val annotatedText = buildAnnotatedString {", depth)
  367. code += "\n" + indent("append(\"#{escape_string(text)}\")", depth + 1)
  368. # Apply partial attributes
  369. partial_attrs.each do |attr|
  370. range = attr['range']
  371. next unless range && range.is_a?(Array) && range.length == 2
  372. start_idx = range[0]
  373. end_idx = range[1]
  374. # Build SpanStyle for this range
  375. span_styles = []
  376. if attr['fontColor']
  377. color_resolved = Helpers::ResourceResolver.process_color(attr['fontColor'], required_imports)
  378. span_styles << "color = #{color_resolved}"
  379. end
  380. if attr['fontSize']
  381. span_styles << "fontSize = #{attr['fontSize']}.sp"
  382. end
  383. if attr['fontWeight']
  384. weight_mapping = {
  385. 'bold' => 'Bold',
  386. 'semibold' => 'SemiBold',
  387. 'medium' => 'Medium',
  388. 'light' => 'Light'
  389. }
  390. weight = weight_mapping[attr['fontWeight'].downcase] || 'Normal'
  391. span_styles << "fontWeight = FontWeight.#{weight}"
  392. end
  393. if attr['background']
  394. background_resolved = Helpers::ResourceResolver.process_color(attr['background'], required_imports)
  395. span_styles << "background = #{background_resolved}"
  396. end
  397. if attr['underline']
  398. required_imports&.add(:text_decoration)
  399. span_styles << "textDecoration = TextDecoration.Underline"
  400. end
  401. if attr['strikethrough']
  402. required_imports&.add(:text_decoration)
  403. span_styles << "textDecoration = TextDecoration.LineThrough"
  404. end
  405. if span_styles.any?
  406. code += "\n" + indent("addStyle(", depth + 1)
  407. code += "\n" + indent("style = SpanStyle(#{span_styles.join(', ')}),", depth + 2)
  408. code += "\n" + indent("start = #{start_idx},", depth + 2)
  409. code += "\n" + indent("end = #{end_idx}", depth + 2)
  410. code += "\n" + indent(")", depth + 1)
  411. end
  412. # Add clickable annotation if onclick/onClick is specified
  413. click_handler = attr['onclick'] || attr['onClick']
  414. if click_handler
  415. # Extract method name from binding format if needed
  416. method_name = if click_handler.match?(/^@\{(.+)\}$/)
  417. click_handler.match(/^@\{(.+)\}$/)[1]
  418. else
  419. click_handler.gsub(':', '')
  420. end
  421. code += "\n" + indent("addStringAnnotation(", depth + 1)
  422. code += "\n" + indent("tag = \"CLICKABLE\",", depth + 2)
  423. code += "\n" + indent("annotation = \"#{method_name}\",", depth + 2)
  424. code += "\n" + indent("start = #{start_idx},", depth + 2)
  425. code += "\n" + indent("end = #{end_idx}", depth + 2)
  426. code += "\n" + indent(")", depth + 1)
  427. end
  428. end
  429. code += "\n" + indent("}", depth)
  430. code += "\n"
  431. # Now use ClickableText with the annotatedString
  432. code += indent("ClickableText(", depth)
  433. code += "\n" + indent("text = annotatedText,", depth + 1)
  434. # Add onClick handler for clickable ranges
  435. if partial_attrs.any? { |attr| attr['onclick'] }
  436. code += "\n" + indent("onClick = { offset ->", depth + 1)
  437. code += "\n" + indent("annotatedText.getStringAnnotations(\"CLICKABLE\", offset, offset)", depth + 2)
  438. code += "\n" + indent(".firstOrNull()?.let { annotation ->", depth + 3)
  439. code += "\n" + indent("viewModel.handlePartialClick(annotation.item)", depth + 4)
  440. code += "\n" + indent("}", depth + 3)
  441. code += "\n" + indent("},", depth + 1)
  442. else
  443. code += "\n" + indent("onClick = { },", depth + 1)
  444. end
  445. # Add style (fontSize, color, etc. for the whole text)
  446. style_code = build_text_style(json_data, depth + 1, required_imports)
  447. if style_code
  448. code += style_code
  449. end
  450. # Build modifiers
  451. modifiers = []
  452. modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
  453. modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  454. modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  455. modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  456. if modifiers.any?
  457. code += Helpers::ModifierBuilder.format(modifiers, depth)
  458. else
  459. code += "\n" + indent("modifier = Modifier", depth + 1)
  460. end
  461. code += "\n" + indent(")", depth)
  462. # Wrap with VisibilityWrapper if needed
  463. Helpers::VisibilityHelper.wrap_with_visibility(json_data, code, depth, required_imports)
  464. end
  465. 1 def self.build_text_style(json_data, depth, required_imports)
  466. 4 style_parts = []
  467. 4 if json_data['fontSize']
  468. 1 style_parts << "fontSize = #{json_data['fontSize']}.sp"
  469. end
  470. 4 if json_data['fontColor']
  471. 1 color_value = Helpers::ResourceResolver.process_color(json_data['fontColor'], required_imports)
  472. 1 style_parts << "color = #{color_value}" if color_value
  473. end
  474. 4 if json_data['textAlign']
  475. 1 required_imports&.add(:text_align)
  476. 1 case json_data['textAlign'].downcase
  477. when 'center'
  478. 1 style_parts << "textAlign = TextAlign.Center"
  479. when 'right'
  480. style_parts << "textAlign = TextAlign.End"
  481. when 'left'
  482. style_parts << "textAlign = TextAlign.Start"
  483. end
  484. end
  485. 4 if style_parts.any?
  486. 3 required_imports&.add(:text_style)
  487. 3 return ",\n" + indent("style = TextStyle(#{style_parts.join(', ')})", depth)
  488. end
  489. nil
  490. end
  491. # Resolve font attribute - returns style string for fontWeight or fontFamily
  492. 1 def self.resolve_font_attribute(json_data, required_imports)
  493. 9 weight_names = ['bold', 'semibold', 'medium', 'light', 'thin', 'extralight', 'heavy', 'black', 'normal']
  494. 9 weight_mapping = {
  495. 'thin' => 'Thin',
  496. 'extralight' => 'ExtraLight',
  497. 'light' => 'Light',
  498. 'normal' => 'Normal',
  499. 'medium' => 'Medium',
  500. 'semibold' => 'SemiBold',
  501. 'bold' => 'Bold',
  502. 'extrabold' => 'ExtraBold',
  503. 'heavy' => 'ExtraBold',
  504. 'black' => 'Black'
  505. }
  506. 9 if json_data['font']
  507. font_value = json_data['font'].to_s.downcase
  508. if weight_names.include?(font_value)
  509. weight = weight_mapping[font_value] || 'Normal'
  510. required_imports&.add(:font_weight)
  511. "fontWeight = FontWeight.#{weight}"
  512. else
  513. required_imports&.add(:font_family)
  514. "fontFamily = FontFamily(Font(R.font.#{json_data['font'].gsub('-', '_').downcase}))"
  515. end
  516. 9 elsif json_data['fontWeight']
  517. 1 weight = weight_mapping[json_data['fontWeight'].downcase] || json_data['fontWeight'].capitalize
  518. 1 required_imports&.add(:font_weight)
  519. 1 "fontWeight = FontWeight.#{weight}"
  520. end
  521. end
  522. 1 def self.escape_string(text)
  523. 32 text.gsub('\\', '\\\\\\\\')
  524. .gsub('"', '\\"')
  525. .gsub("\n", '\\n')
  526. .gsub("\r", '\\r')
  527. .gsub("\t", '\\t')
  528. end
  529. 1 def self.quote(text)
  530. # Escape special characters properly
  531. 2 escaped = text.gsub('\\', '\\\\\\\\') # Escape backslashes first
  532. .gsub('"', '\\"') # Escape quotes
  533. .gsub("\n", '\\n') # Escape newlines
  534. .gsub("\r", '\\r') # Escape carriage returns
  535. .gsub("\t", '\\t') # Escape tabs
  536. 2 "\"#{escaped}\""
  537. end
  538. 1 def self.indent(text, level)
  539. 357 return text if level == 0
  540. 237 spaces = ' ' * level
  541. 237 text.split("\n").map { |line|
  542. 239 line.empty? ? line : spaces + line
  543. }.join("\n")
  544. end
  545. end
  546. end
  547. end
  548. end

lib/compose/components/textfield_component.rb

78.71% lines covered

202 relevant lines. 159 lines covered and 43 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class TextFieldComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. # TextField uses 'text' for value and supports both 'hint' and 'placeholder'
  10. # For TextField value, we need direct data binding (not string interpolation)
  11. 40 raw_text = json_data['text'] || ''
  12. 40 value = if raw_text.match(/@\{([^}]+)\}/)
  13. 3 variable = $1
  14. 3 var_name = variable.include?(' ?? ') ? variable.split(' ?? ')[0].strip : variable
  15. 3 "data.#{var_name}"
  16. else
  17. 37 Helpers::ResourceResolver.process_text(raw_text, required_imports)
  18. end
  19. 40 placeholder_text = json_data['hint'] || json_data['placeholder'] || ''
  20. 40 placeholder = placeholder_text.empty? ? '""' : Helpers::ResourceResolver.process_text(placeholder_text, required_imports)
  21. 40 is_secure = json_data['secure'] == true
  22. # Check if we need to wrap in Box for margins
  23. 40 has_margins = json_data['margins'] || json_data['topMargin'] || json_data['bottomMargin'] ||
  24. json_data['leftMargin'] || json_data['rightMargin']
  25. # Always use CustomTextField
  26. 40 required_imports&.add(:custom_textfield)
  27. 40 required_imports&.add(:visual_transformation) if is_secure
  28. 40 code = ""
  29. 40 if has_margins
  30. 2 required_imports&.add(:box)
  31. 2 code = indent("CustomTextFieldWithMargins(", depth)
  32. else
  33. 38 code = indent("CustomTextField(", depth)
  34. end
  35. 40 code += "\n" + indent("value = #{value},", depth + 1)
  36. # Handle onValueChange/onTextChange
  37. # Data binding: update via viewModel.updateData to trigger StateFlow, then call onTextChange callback if specified
  38. 40 if json_data['text'] && json_data['text'].match(/@\{([^}]+)\}/)
  39. 3 variable = extract_variable_name(json_data['text'])
  40. 3 if json_data['onTextChange']
  41. # Data binding + explicit callback
  42. # Strip @{} binding syntax from callback name
  43. callback_name = extract_binding_name(json_data['onTextChange'])
  44. code += "\n" + indent("onValueChange = { newValue -> viewModel.updateData(mapOf(\"#{variable}\" to newValue)); data.#{callback_name}?.invoke() },", depth + 1)
  45. else
  46. # Data binding only
  47. 3 code += "\n" + indent("onValueChange = { newValue -> viewModel.updateData(mapOf(\"#{variable}\" to newValue)) },", depth + 1)
  48. end
  49. 37 elsif json_data['onTextChange']
  50. # Explicit callback only (no data binding)
  51. # Strip @{} binding syntax from callback name
  52. 2 callback_name = extract_binding_name(json_data['onTextChange'])
  53. 2 code += "\n" + indent("onValueChange = { newValue -> data.#{callback_name}?.invoke() },", depth + 1)
  54. else
  55. 35 code += "\n" + indent("onValueChange = { },", depth + 1)
  56. end
  57. # For CustomTextFieldWithMargins, we need to specify modifiers differently
  58. 40 if has_margins
  59. # Box modifier with margins
  60. 2 box_modifiers = []
  61. 2 box_modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  62. 2 if box_modifiers.any?
  63. 2 code += "\n" + indent("boxModifier = Modifier", depth + 1)
  64. 2 box_modifiers.each do |mod|
  65. 2 code += "\n" + indent(" #{mod}", depth + 1)
  66. end
  67. 2 code += ","
  68. end
  69. # TextField modifier (size and weight, padding goes to contentPadding)
  70. 2 textfield_modifiers = []
  71. 2 textfield_modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  72. 2 textfield_modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
  73. 2 if textfield_modifiers.any?
  74. code += "\n" + indent("textFieldModifier = Modifier", depth + 1)
  75. textfield_modifiers.each do |mod|
  76. code += "\n" + indent(" #{mod}", depth + 1)
  77. end
  78. code += ","
  79. end
  80. else
  81. # Regular modifiers for CustomTextField (size, margins, and weight, padding goes to contentPadding)
  82. 38 modifiers = []
  83. 38 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  84. 38 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  85. 38 modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
  86. 38 if modifiers.any?
  87. 1 code += "\n" + indent("modifier = Modifier", depth + 1)
  88. 1 modifiers.each do |mod|
  89. 2 code += "\n" + indent(" #{mod}", depth + 1)
  90. end
  91. 1 code += ","
  92. end
  93. end
  94. # Add placeholder/hint with styling
  95. # Always use Configuration.TextField.defaultPlaceholderColor if hintColor is not specified
  96. 40 if placeholder && placeholder != '""'
  97. 3 required_imports&.add(:configuration)
  98. 3 placeholder_code = "placeholder = { Text("
  99. 3 placeholder_code += "\n" + indent("text = #{placeholder}", depth + 2)
  100. # Use hintColor if specified, otherwise use Configuration default
  101. 3 if json_data['hintColor']
  102. 1 hint_color = Helpers::ResourceResolver.process_color(json_data['hintColor'], required_imports)
  103. 1 placeholder_code += ",\n" + indent("color = #{hint_color}", depth + 2)
  104. else
  105. 2 placeholder_code += ",\n" + indent("color = Configuration.TextField.defaultPlaceholderColor", depth + 2)
  106. end
  107. 3 if json_data['hintFontSize']
  108. 1 placeholder_code += ",\n" + indent("fontSize = #{json_data['hintFontSize']}.sp", depth + 2)
  109. end
  110. 3 if json_data['hintFont'] == 'bold'
  111. 1 placeholder_code += ",\n" + indent("fontWeight = FontWeight.Bold", depth + 2)
  112. end
  113. 3 placeholder_code += "\n" + indent(") }", depth + 1)
  114. 3 code += "\n" + indent(placeholder_code, depth + 1) + ","
  115. end
  116. # Add visual transformation for secure fields
  117. 40 if is_secure
  118. 1 code += "\n" + indent("visualTransformation = PasswordVisualTransformation(),", depth + 1)
  119. end
  120. # Add custom TextField parameters
  121. # Shape with corner radius
  122. 40 if json_data['cornerRadius']
  123. 1 required_imports&.add(:shape)
  124. 1 code += "\n" + indent("shape = RoundedCornerShape(#{json_data['cornerRadius']}.dp),", depth + 1)
  125. end
  126. # Content padding - internal padding within the text field
  127. # Supports: paddings (array or single value), fieldPadding (legacy single value)
  128. 40 if json_data['paddings']
  129. required_imports&.add(:padding_values)
  130. paddings = json_data['paddings']
  131. if paddings.is_a?(Array)
  132. case paddings.length
  133. when 1
  134. code += "\n" + indent("contentPadding = PaddingValues(#{paddings[0]}.dp),", depth + 1)
  135. when 2
  136. # [vertical, horizontal]
  137. code += "\n" + indent("contentPadding = PaddingValues(horizontal = #{paddings[1]}.dp, vertical = #{paddings[0]}.dp),", depth + 1)
  138. when 4
  139. # [top, right, bottom, left]
  140. code += "\n" + indent("contentPadding = PaddingValues(start = #{paddings[3]}.dp, top = #{paddings[0]}.dp, end = #{paddings[1]}.dp, bottom = #{paddings[2]}.dp),", depth + 1)
  141. end
  142. else
  143. code += "\n" + indent("contentPadding = PaddingValues(#{paddings}.dp),", depth + 1)
  144. end
  145. 40 elsif json_data['fieldPadding']
  146. required_imports&.add(:padding_values)
  147. code += "\n" + indent("contentPadding = PaddingValues(#{json_data['fieldPadding']}.dp),", depth + 1)
  148. end
  149. # Text padding left - start padding for text content
  150. 40 if json_data['textPaddingLeft']
  151. code += "\n" + indent("textPaddingStart = #{json_data['textPaddingLeft']}.dp,", depth + 1)
  152. end
  153. # Background colors
  154. 40 if json_data['background']
  155. 1 bg_color = Helpers::ResourceResolver.process_color(json_data['background'], required_imports)
  156. 1 code += "\n" + indent("backgroundColor = #{bg_color},", depth + 1)
  157. end
  158. 40 if json_data['highlightBackground']
  159. 1 highlight_bg_color = Helpers::ResourceResolver.process_color(json_data['highlightBackground'], required_imports)
  160. 1 code += "\n" + indent("highlightBackgroundColor = #{highlight_bg_color},", depth + 1)
  161. end
  162. # Border color for outlined text fields
  163. 40 if json_data['borderColor']
  164. 1 border_color = Helpers::ResourceResolver.process_color(json_data['borderColor'], required_imports)
  165. 1 code += "\n" + indent("borderColor = #{border_color},", depth + 1)
  166. end
  167. # Border style handling
  168. # borderStyle: none, line, bezel, roundedRect
  169. 40 if json_data['borderStyle']
  170. case json_data['borderStyle'].downcase
  171. when 'none'
  172. code += "\n" + indent("isOutlined = false,", depth + 1)
  173. when 'line', 'bezel', 'roundedrect'
  174. code += "\n" + indent("isOutlined = true,", depth + 1)
  175. end
  176. # Set isOutlined and isSecure flags
  177. # Automatically use outlined style if borderColor or borderWidth is specified
  178. 40 elsif json_data['outlined'] == true || json_data['borderColor'] || json_data['borderWidth']
  179. 2 code += "\n" + indent("isOutlined = true,", depth + 1)
  180. end
  181. 40 if is_secure
  182. 1 code += "\n" + indent("isSecure = true,", depth + 1)
  183. end
  184. # Text styling - always add this last before closing
  185. # Always include textStyle with at least a default color
  186. 40 required_imports&.add(:text_style)
  187. 40 style_parts = []
  188. 40 style_parts << "fontSize = #{json_data['fontSize']}.sp" if json_data['fontSize']
  189. # Use fontColor if specified, otherwise default to black
  190. 40 if json_data['fontColor']
  191. 1 color_value = Helpers::ResourceResolver.process_color(json_data['fontColor'], required_imports)
  192. 1 style_parts << "color = #{color_value}" if color_value
  193. else
  194. # Default to black text
  195. 39 default_color = Helpers::ResourceResolver.process_color('#000000', required_imports)
  196. 39 style_parts << "color = #{default_color}"
  197. end
  198. 40 if json_data['textAlign']
  199. 3 required_imports&.add(:text_align)
  200. 3 case json_data['textAlign'].downcase
  201. when 'center'
  202. 1 style_parts << "textAlign = TextAlign.Center"
  203. when 'right'
  204. 1 style_parts << "textAlign = TextAlign.End"
  205. when 'left'
  206. 1 style_parts << "textAlign = TextAlign.Start"
  207. end
  208. end
  209. 40 if style_parts.any?
  210. # Remove trailing comma before adding textStyle
  211. 40 if code.end_with?(',')
  212. 40 code = code[0..-2]
  213. end
  214. 40 code += ",\n" + indent("textStyle = TextStyle(#{style_parts.join(', ')})", depth + 1)
  215. end
  216. # Add focus/blur event handlers
  217. 40 if json_data['onFocus']
  218. 1 code += ",\n" + indent("onFocus = { data.#{json_data['onFocus']}?.invoke() }", depth + 1)
  219. end
  220. 40 if json_data['onBlur']
  221. 1 code += ",\n" + indent("onBlur = { data.#{json_data['onBlur']}?.invoke() }", depth + 1)
  222. end
  223. 40 if json_data['onBeginEditing']
  224. 1 code += ",\n" + indent("onBeginEditing = { data.#{json_data['onBeginEditing']}?.invoke() }", depth + 1)
  225. end
  226. 40 if json_data['onEndEditing']
  227. 1 code += ",\n" + indent("onEndEditing = { data.#{json_data['onEndEditing']}?.invoke() }", depth + 1)
  228. end
  229. # Keyboard options (input, returnKeyType, contentType, autocapitalizationType, autocorrectionType)
  230. 40 keyboard_options = []
  231. # Input type / contentType - contentType takes priority
  232. 40 if json_data['contentType']
  233. required_imports&.add(:keyboard_type)
  234. keyboard_type = case json_data['contentType'].downcase
  235. when 'emailaddress', 'email'
  236. 'KeyboardType.Email'
  237. when 'password', 'newpassword'
  238. 'KeyboardType.Password'
  239. when 'telephonenumber', 'phone'
  240. 'KeyboardType.Phone'
  241. when 'url'
  242. 'KeyboardType.Uri'
  243. when 'creditcardnumber'
  244. 'KeyboardType.Number'
  245. else
  246. 'KeyboardType.Text'
  247. end
  248. keyboard_options << "keyboardType = #{keyboard_type}"
  249. 40 elsif json_data['input']
  250. 6 required_imports&.add(:keyboard_type)
  251. 6 keyboard_type = case json_data['input']
  252. when 'email'
  253. 1 'KeyboardType.Email'
  254. when 'password'
  255. 1 'KeyboardType.Password'
  256. when 'number'
  257. 1 'KeyboardType.Number'
  258. when 'decimal'
  259. 1 'KeyboardType.Decimal'
  260. when 'phone'
  261. 1 'KeyboardType.Phone'
  262. else
  263. 1 'KeyboardType.Text'
  264. end
  265. 6 keyboard_options << "keyboardType = #{keyboard_type}"
  266. end
  267. 40 if json_data['returnKeyType']
  268. 6 required_imports&.add(:ime_action)
  269. 6 ime_action = case json_data['returnKeyType']
  270. when 'Done'
  271. 1 'ImeAction.Done'
  272. when 'Next'
  273. 1 'ImeAction.Next'
  274. when 'Search'
  275. 1 'ImeAction.Search'
  276. when 'Send'
  277. 1 'ImeAction.Send'
  278. when 'Go'
  279. 1 'ImeAction.Go'
  280. else
  281. 1 'ImeAction.Default'
  282. end
  283. 6 keyboard_options << "imeAction = #{ime_action}"
  284. end
  285. # Auto-capitalization type
  286. 40 if json_data['autocapitalizationType']
  287. required_imports&.add(:keyboard_capitalization)
  288. capitalization = case json_data['autocapitalizationType'].downcase
  289. when 'none'
  290. 'KeyboardCapitalization.None'
  291. when 'words'
  292. 'KeyboardCapitalization.Words'
  293. when 'sentences'
  294. 'KeyboardCapitalization.Sentences'
  295. when 'allcharacters', 'characters'
  296. 'KeyboardCapitalization.Characters'
  297. else
  298. 'KeyboardCapitalization.None'
  299. end
  300. keyboard_options << "capitalization = #{capitalization}"
  301. end
  302. # Auto-correction type
  303. 40 if json_data['autocorrectionType']
  304. auto_correct = case json_data['autocorrectionType'].downcase
  305. when 'no', 'false', 'off'
  306. 'false'
  307. when 'yes', 'true', 'on', 'default'
  308. 'true'
  309. else
  310. 'true'
  311. end
  312. keyboard_options << "autoCorrect = #{auto_correct}"
  313. end
  314. 40 if keyboard_options.any?
  315. 12 code += ",\n" + indent("keyboardOptions = KeyboardOptions(#{keyboard_options.join(', ')})", depth + 1)
  316. end
  317. # Remove trailing comma and close
  318. 40 if code.end_with?(',')
  319. code = code[0..-2]
  320. end
  321. 40 code += "\n" + indent(")", depth)
  322. 40 code
  323. end
  324. 1 private
  325. 1 def self.extract_variable_name(text)
  326. 6 if text && text.match(/@\{([^}]+)\}/)
  327. 5 $1.split('.').last
  328. else
  329. 1 'value'
  330. end
  331. end
  332. # Strip @{} binding syntax from a value and return the property name
  333. 1 def self.extract_binding_name(value)
  334. 2 if value && value.match(/@\{([^}]+)\}/)
  335. 1 $1
  336. else
  337. 1 value
  338. end
  339. end
  340. 1 def self.indent(text, level)
  341. 247 return text if level == 0
  342. 166 spaces = ' ' * level
  343. 166 text.split("\n").map { |line|
  344. 177 line.empty? ? line : spaces + line
  345. }.join("\n")
  346. end
  347. end
  348. end
  349. end
  350. end

lib/compose/components/textview_component.rb

80.22% lines covered

182 relevant lines. 146 lines covered and 36 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class TextViewComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. # TextView is multi-line text input (like TextArea)
  10. # Uses 'text' for value and supports both 'hint' and 'placeholder' (hint is primary)
  11. 45 value = process_data_binding(json_data['text'] || '')
  12. 45 placeholder = json_data['hint'] || json_data['placeholder'] || ''
  13. # Check if we need to wrap in Box for margins
  14. 45 has_margins = json_data['margins'] || json_data['topMargin'] || json_data['bottomMargin'] ||
  15. json_data['leftMargin'] || json_data['rightMargin']
  16. # Always use CustomTextField
  17. 45 required_imports&.add(:custom_textfield)
  18. 45 code = ""
  19. 45 if has_margins
  20. 11 required_imports&.add(:box)
  21. 11 code = indent("CustomTextFieldWithMargins(", depth)
  22. else
  23. 34 code = indent("CustomTextField(", depth)
  24. end
  25. 45 code += "\n" + indent("value = #{value},", depth + 1)
  26. # onValueChange handler
  27. # Data binding: directly update data property, then call onTextChange callback if specified
  28. 45 if json_data['text'] && json_data['text'].match(/@\{([^}]+)\}/)
  29. 2 variable = extract_variable_name(json_data['text'])
  30. 2 if json_data['onTextChange']
  31. # Data binding + explicit callback
  32. code += "\n" + indent("onValueChange = { newValue -> data.#{variable} = newValue; data.#{json_data['onTextChange']}?.invoke() },", depth + 1)
  33. else
  34. # Data binding only
  35. 2 code += "\n" + indent("onValueChange = { newValue -> data.#{variable} = newValue },", depth + 1)
  36. end
  37. 43 elsif json_data['onTextChange']
  38. # Explicit callback only (no data binding)
  39. 1 code += "\n" + indent("onValueChange = { newValue -> data.#{json_data['onTextChange']}?.invoke() },", depth + 1)
  40. else
  41. 42 code += "\n" + indent("onValueChange = { },", depth + 1)
  42. end
  43. # For CustomTextFieldWithMargins, we need to specify modifiers differently
  44. 45 if has_margins
  45. # Box modifier with margins
  46. 11 box_modifiers = []
  47. 11 box_modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  48. 11 if box_modifiers.any?
  49. 11 code += "\n" + indent("boxModifier = Modifier", depth + 1)
  50. 11 box_modifiers.each do |mod|
  51. 11 code += "\n" + indent(" #{mod}", depth + 1)
  52. end
  53. 11 code += ","
  54. end
  55. # TextField modifier
  56. 11 textfield_modifiers = []
  57. # Size - default to fillMaxWidth for text areas
  58. 11 if json_data['width'] == 'matchParent' || !json_data['width']
  59. 10 textfield_modifiers << ".fillMaxWidth()"
  60. else
  61. 1 textfield_modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  62. end
  63. # Height for multi-line
  64. 11 if json_data['height']
  65. 3 if json_data['height'] == 'matchParent'
  66. 1 textfield_modifiers << ".fillMaxHeight()"
  67. 2 elsif json_data['height'] == 'wrapContent'
  68. 1 textfield_modifiers << ".wrapContentHeight()"
  69. else
  70. 1 textfield_modifiers << ".height(#{json_data['height']}.dp)"
  71. end
  72. else
  73. # Default height for text area
  74. 8 textfield_modifiers << ".height(120.dp)"
  75. end
  76. 11 textfield_modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  77. 11 if textfield_modifiers.any?
  78. 11 code += "\n" + indent("textFieldModifier = Modifier", depth + 1)
  79. 11 textfield_modifiers.each do |mod|
  80. 22 code += "\n" + indent(" #{mod}", depth + 1)
  81. end
  82. 11 code += ","
  83. end
  84. else
  85. # Regular modifiers for CustomTextField
  86. 34 modifiers = []
  87. # Size - default to fillMaxWidth for text areas
  88. 34 if json_data['width'] == 'matchParent' || !json_data['width']
  89. 33 modifiers << ".fillMaxWidth()"
  90. else
  91. 1 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  92. end
  93. # Height for multi-line
  94. 34 if json_data['height']
  95. 3 if json_data['height'] == 'matchParent'
  96. 1 modifiers << ".fillMaxHeight()"
  97. 2 elsif json_data['height'] == 'wrapContent'
  98. 1 modifiers << ".wrapContentHeight()"
  99. else
  100. 1 modifiers << ".height(#{json_data['height']}.dp)"
  101. end
  102. else
  103. # Default height for text area
  104. 31 modifiers << ".height(120.dp)"
  105. end
  106. 34 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  107. 34 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  108. 34 modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
  109. 34 if modifiers.any?
  110. 34 code += "\n" + indent("modifier = Modifier", depth + 1)
  111. 34 modifiers.each do |mod|
  112. 68 code += "\n" + indent(" #{mod}", depth + 1)
  113. end
  114. 34 code += ","
  115. end
  116. end
  117. # Placeholder with optional line height styling
  118. 45 if placeholder && !placeholder.empty?
  119. 2 if json_data['hintLineHeightMultiple']
  120. # Complex placeholder with line height
  121. required_imports&.add(:text_style)
  122. base_size = json_data['hintFontSize'] || json_data['fontSize'] || 14
  123. line_height = base_size.to_f * json_data['hintLineHeightMultiple'].to_f
  124. code += "\n" + indent("placeholder = {", depth + 1)
  125. code += "\n" + indent("Text(", depth + 2)
  126. code += "\n" + indent("text = #{quote(placeholder)},", depth + 3)
  127. code += "\n" + indent("style = TextStyle(lineHeight = #{line_height}.sp)", depth + 3)
  128. code += "\n" + indent(")", depth + 2)
  129. code += "\n" + indent("},", depth + 1)
  130. else
  131. 2 code += "\n" + indent("placeholder = { Text(#{quote(placeholder)}) },", depth + 1)
  132. end
  133. end
  134. # Container inset - internal padding
  135. 45 if json_data['containerInset']
  136. inset = json_data['containerInset']
  137. if inset.is_a?(Array) && inset.length == 4
  138. code += "\n" + indent("contentPadding = PaddingValues(top = #{inset[0]}.dp, end = #{inset[1]}.dp, bottom = #{inset[2]}.dp, start = #{inset[3]}.dp),", depth + 1)
  139. elsif inset.is_a?(Numeric)
  140. code += "\n" + indent("contentPadding = PaddingValues(#{inset}.dp),", depth + 1)
  141. end
  142. end
  143. # Flexible height - auto-expand based on content
  144. 45 if json_data['flexible']
  145. code += "\n" + indent("// flexible: true - height adjusts to content", depth + 1)
  146. end
  147. # Shape with corner radius
  148. 45 if json_data['cornerRadius']
  149. 1 required_imports&.add(:shape)
  150. 1 code += "\n" + indent("shape = RoundedCornerShape(#{json_data['cornerRadius']}.dp),", depth + 1)
  151. end
  152. # Background colors
  153. 45 if json_data['background']
  154. 1 bg_color = Helpers::ResourceResolver.process_color(json_data['background'], required_imports)
  155. 1 code += "\n" + indent("backgroundColor = #{bg_color},", depth + 1)
  156. end
  157. 45 if json_data['highlightBackground']
  158. 1 highlight_color = Helpers::ResourceResolver.process_color(json_data['highlightBackground'], required_imports)
  159. 1 code += "\n" + indent("highlightBackgroundColor = #{highlight_color},", depth + 1)
  160. end
  161. # Border color for outlined text fields
  162. 45 if json_data['borderColor']
  163. 1 border_color = Helpers::ResourceResolver.process_color(json_data['borderColor'], required_imports)
  164. 1 code += "\n" + indent("borderColor = #{border_color},", depth + 1)
  165. end
  166. # Set isOutlined flag (TextView usually wants outlined style)
  167. 45 code += "\n" + indent("isOutlined = true,", depth + 1)
  168. # Max lines for TextView
  169. 45 if json_data['maxLines']
  170. 1 code += "\n" + indent("maxLines = #{json_data['maxLines']},", depth + 1)
  171. else
  172. # Default to multiple lines
  173. 44 code += "\n" + indent("maxLines = Int.MAX_VALUE,", depth + 1)
  174. end
  175. # Single line false for multi-line
  176. 45 code += "\n" + indent("singleLine = false,", depth + 1)
  177. # Line break mode (overflow handling)
  178. 45 if json_data['lineBreakMode']
  179. # Note: For multi-line TextField, overflow is less relevant
  180. # but we include it for completeness
  181. case json_data['lineBreakMode'].to_s.downcase
  182. when 'clip'
  183. code += "\n" + indent("// lineBreakMode: clip", depth + 1)
  184. when 'tail', 'truncatetail'
  185. code += "\n" + indent("// lineBreakMode: truncate tail", depth + 1)
  186. when 'head', 'truncatehead'
  187. code += "\n" + indent("// lineBreakMode: truncate head", depth + 1)
  188. when 'middle', 'truncatemiddle'
  189. code += "\n" + indent("// lineBreakMode: truncate middle", depth + 1)
  190. when 'wordwrap', 'word'
  191. code += "\n" + indent("// lineBreakMode: word wrap (default)", depth + 1)
  192. when 'charwrap', 'char'
  193. code += "\n" + indent("// lineBreakMode: character wrap", depth + 1)
  194. end
  195. end
  196. # Text styling
  197. 45 if json_data['fontSize'] || json_data['fontColor']
  198. 3 required_imports&.add(:text_style)
  199. 3 style_parts = []
  200. 3 style_parts << "fontSize = #{json_data['fontSize']}.sp" if json_data['fontSize']
  201. 3 if json_data['fontColor']
  202. 2 font_color = Helpers::ResourceResolver.process_color(json_data['fontColor'], required_imports)
  203. 2 style_parts << "color = #{font_color}"
  204. end
  205. 3 if style_parts.any?
  206. 3 code += "\n" + indent("textStyle = TextStyle(#{style_parts.join(', ')})", depth + 1)
  207. end
  208. end
  209. # Keyboard options
  210. 45 keyboard_options = []
  211. # keyboardType
  212. 45 if json_data['keyboardType'] || json_data['input']
  213. required_imports&.add(:keyboard_type)
  214. input_type = json_data['keyboardType'] || json_data['input']
  215. keyboard_type = case input_type.to_s.downcase
  216. when 'email'
  217. 'KeyboardType.Email'
  218. when 'number'
  219. 'KeyboardType.Number'
  220. when 'decimal'
  221. 'KeyboardType.Decimal'
  222. when 'phone'
  223. 'KeyboardType.Phone'
  224. when 'url'
  225. 'KeyboardType.Uri'
  226. else
  227. 'KeyboardType.Text'
  228. end
  229. keyboard_options << "keyboardType = #{keyboard_type}"
  230. end
  231. 45 if json_data['returnKeyType']
  232. 4 required_imports&.add(:ime_action)
  233. 4 ime_action = case json_data['returnKeyType']
  234. when 'Done'
  235. 1 'ImeAction.Done'
  236. when 'Next'
  237. 1 'ImeAction.Next'
  238. when 'Default'
  239. 1 'ImeAction.Default'
  240. else
  241. 1 'ImeAction.Default'
  242. end
  243. 4 keyboard_options << "imeAction = #{ime_action}"
  244. end
  245. 45 if keyboard_options.any?
  246. 4 code += ",\n" + indent("keyboardOptions = KeyboardOptions(#{keyboard_options.join(', ')})", depth + 1)
  247. end
  248. # scrollEnabled - controls vertical scroll within TextView
  249. 45 if json_data.key?('scrollEnabled')
  250. # In Compose, scrolling is controlled via verticalScroll modifier
  251. # For TextField, we just note it - actual implementation may need custom handling
  252. if json_data['scrollEnabled'] == false
  253. code += ",\n" + indent("// scrollEnabled = false - scrolling disabled", depth + 1)
  254. end
  255. end
  256. # hideOnFocused - hide placeholder when focused
  257. # Note: Compose TextField hides placeholder by default when there's text
  258. # This is primarily for when you want different behavior
  259. 45 if json_data.key?('hideOnFocused')
  260. code += ",\n" + indent("// hideOnFocused = #{json_data['hideOnFocused']}", depth + 1)
  261. end
  262. # Enabled state
  263. 45 if json_data.key?('enabled')
  264. 3 if json_data['enabled'].is_a?(String) && json_data['enabled'].start_with?('@{')
  265. 1 variable = json_data['enabled'].match(/@\{([^}]+)\}/)[1]
  266. 1 code += ",\n" + indent("enabled = data.#{variable}", depth + 1)
  267. else
  268. 2 code += ",\n" + indent("enabled = #{json_data['enabled']}", depth + 1)
  269. end
  270. end
  271. # Remove trailing comma and close
  272. 45 if code.end_with?(',')
  273. 35 code = code[0..-2]
  274. end
  275. 45 code += "\n" + indent(")", depth)
  276. 45 code
  277. end
  278. 1 private
  279. 1 def self.process_data_binding(text)
  280. 49 return quote(text) unless text.is_a?(String)
  281. 49 if text.match(/@\{([^}]+)\}/)
  282. 4 variable = $1
  283. 4 if variable.include?(' ?? ')
  284. 2 parts = variable.split(' ?? ')
  285. 2 var_name = parts[0].strip
  286. 2 "data.#{var_name}"
  287. else
  288. 2 "data.#{variable}"
  289. end
  290. else
  291. 45 quote(text)
  292. end
  293. end
  294. 1 def self.extract_variable_name(text)
  295. 5 if text && text.match(/@\{([^}]+)\}/)
  296. 3 $1.split('.').last
  297. else
  298. 2 'value'
  299. end
  300. end
  301. 1 def self.quote(text)
  302. # Escape special characters properly
  303. 52 escaped = text.gsub('\\', '\\\\\\\\') # Escape backslashes first
  304. .gsub('"', '\\"') # Escape quotes
  305. .gsub("\n", '\\n') # Escape newlines
  306. .gsub("\r", '\\r') # Escape carriage returns
  307. .gsub("\t", '\\t') # Escape tabs
  308. 52 "\"#{escaped}\""
  309. end
  310. 1 def self.indent(text, level)
  311. 493 return text if level == 0
  312. 402 spaces = ' ' * level
  313. 402 text.split("\n").map { |line|
  314. 405 line.empty? ? line : spaces + line
  315. }.join("\n")
  316. end
  317. end
  318. end
  319. end
  320. end

lib/compose/components/toggle_component.rb

96.23% lines covered

53 relevant lines. 51 lines covered and 2 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class ToggleComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. # Toggle in iOS maps to Switch in Android
  10. 13 code = indent("Switch(", depth)
  11. # Checked state
  12. 13 checked_value = if json_data['data']
  13. 1 "@{#{json_data['data']}}"
  14. 12 elsif json_data['isOn']
  15. 1 json_data['isOn'].to_s
  16. else
  17. 11 'false'
  18. end
  19. # Process data binding
  20. 13 if checked_value.start_with?('@{')
  21. 1 variable = checked_value[2..-2]
  22. 1 code += "\n" + indent("checked = data.#{variable},", depth + 1)
  23. 1 code += "\n" + indent("onCheckedChange = { newValue -> data.#{variable} = newValue },", depth + 1)
  24. else
  25. 12 code += "\n" + indent("checked = #{checked_value},", depth + 1)
  26. # Handle onclick (lowercase) -> selector format only
  27. # onClick (camelCase) -> binding format only
  28. 12 if json_data['onclick']
  29. 1 handler_call = Helpers::ModifierBuilder.get_event_handler_call(json_data['onclick'], is_camel_case: false)
  30. 1 code += "\n" + indent("onCheckedChange = { #{handler_call} },", depth + 1)
  31. 11 elsif json_data['onClick']
  32. handler_call = Helpers::ModifierBuilder.get_event_handler_call(json_data['onClick'], is_camel_case: true)
  33. code += "\n" + indent("onCheckedChange = { #{handler_call} },", depth + 1)
  34. else
  35. 11 code += "\n" + indent("onCheckedChange = { },", depth + 1)
  36. end
  37. end
  38. # Build modifiers
  39. 13 modifiers = []
  40. 13 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  41. 13 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  42. 13 modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
  43. # Add weight modifier if in Row or Column
  44. 13 if parent_type == 'Row' || parent_type == 'Column'
  45. 2 modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
  46. end
  47. 13 code += Helpers::ModifierBuilder.format(modifiers, depth)
  48. # Colors if specified
  49. 13 if json_data['tintColor'] || json_data['backgroundColor']
  50. 3 required_imports&.add(:switch_colors)
  51. 3 code += ",\n" + indent("colors = SwitchDefaults.colors(", depth + 1)
  52. 3 if json_data['tintColor']
  53. 2 checkedthumbcolor_resolved = Helpers::ResourceResolver.process_color(json_data['tintColor'], required_imports)
  54. 2 code += "\n" + indent("checkedThumbColor = #{checkedthumbcolor_resolved},", depth + 2)
  55. 2 checkedtrackcolor_resolved = Helpers::ResourceResolver.process_color(json_data['tintColor'], required_imports)
  56. 2 code += "\n" + indent("checkedTrackColor = #{checkedtrackcolor_resolved}.copy(alpha = 0.5f)", depth + 2)
  57. end
  58. 3 if json_data['backgroundColor']
  59. 2 code += ",\n" if json_data['tintColor']
  60. 2 uncheckedtrackcolor_resolved = Helpers::ResourceResolver.process_color(json_data['backgroundColor'], required_imports)
  61. 2 code += "\n" + indent("uncheckedTrackColor = #{uncheckedtrackcolor_resolved}", depth + 2)
  62. end
  63. 3 code += "\n" + indent(")", depth + 1)
  64. end
  65. 13 code += "\n" + indent(")", depth)
  66. 13 code
  67. end
  68. 1 private
  69. 1 def self.indent(text, level)
  70. 67 return text if level == 0
  71. 40 spaces = ' ' * level
  72. 40 text.split("\n").map { |line|
  73. 42 line.empty? ? line : spaces + line
  74. }.join("\n")
  75. end
  76. end
  77. end
  78. end
  79. end

lib/compose/components/web_component.rb

100.0% lines covered

52 relevant lines. 52 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class WebComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. 19 required_imports&.add(:webview)
  10. # Web uses 'url' for the web page URL
  11. 19 url = if json_data['url'] && json_data['url'].match(/@\{([^}]+)\}/)
  12. 2 variable = $1
  13. 2 "data.#{variable}"
  14. 17 elsif json_data['url']
  15. 2 "\"#{json_data['url']}\""
  16. else
  17. 15 '""'
  18. end
  19. # Generate WebView using AndroidView
  20. 19 code = indent("AndroidView(", depth)
  21. 19 code += "\n" + indent("factory = { context ->", depth + 1)
  22. 19 code += "\n" + indent("WebView(context).apply {", depth + 2)
  23. # WebView settings
  24. 19 code += "\n" + indent("settings.javaScriptEnabled = #{json_data['javaScriptEnabled'] != false}", depth + 3)
  25. 19 if json_data['userAgent']
  26. 1 code += "\n" + indent("settings.userAgentString = \"#{json_data['userAgent']}\"", depth + 3)
  27. end
  28. 19 if json_data['allowZoom']
  29. 1 code += "\n" + indent("settings.builtInZoomControls = true", depth + 3)
  30. 1 code += "\n" + indent("settings.displayZoomControls = false", depth + 3)
  31. end
  32. # Load URL
  33. 19 code += "\n" + indent("loadUrl(#{url})", depth + 3)
  34. # WebViewClient for handling navigation
  35. 19 code += "\n" + indent("webViewClient = WebViewClient()", depth + 3)
  36. # WebChromeClient for JavaScript alerts
  37. 19 if json_data['javaScriptEnabled'] != false
  38. 17 code += "\n" + indent("webChromeClient = WebChromeClient()", depth + 3)
  39. end
  40. 19 code += "\n" + indent("}", depth + 2)
  41. 19 code += "\n" + indent("},", depth + 1)
  42. # Update callback to handle URL changes
  43. 19 code += "\n" + indent("update = { webView ->", depth + 1)
  44. 19 if json_data['url'] && json_data['url'].match(/@\{([^}]+)\}/)
  45. 2 code += "\n" + indent("webView.loadUrl(#{url})", depth + 2)
  46. end
  47. 19 code += "\n" + indent("},", depth + 1)
  48. # Build modifiers
  49. 19 modifiers = []
  50. # Default size for WebView
  51. 19 if !json_data['width'] && !json_data['height']
  52. 18 modifiers << ".fillMaxSize()"
  53. else
  54. 1 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  55. end
  56. 19 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  57. 19 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  58. 19 modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
  59. # Border for WebView
  60. 19 if json_data['borderWidth'] && json_data['borderColor']
  61. 1 required_imports&.add(:border)
  62. 1 modifiers << ".border(#{json_data['borderWidth']}.dp, Helpers::ResourceResolver.process_color('#{json_data['borderColor']}', required_imports))"
  63. end
  64. 19 code += Helpers::ModifierBuilder.format(modifiers, depth)
  65. 19 code += "\n" + indent(")", depth)
  66. 19 code
  67. end
  68. 1 private
  69. 1 def self.indent(text, level)
  70. 236 return text if level == 0
  71. 197 spaces = ' ' * level
  72. 197 text.split("\n").map { |line|
  73. 200 line.empty? ? line : spaces + line
  74. }.join("\n")
  75. end
  76. end
  77. end
  78. end
  79. end

lib/compose/components/webview_component.rb

100.0% lines covered

42 relevant lines. 42 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 module KjuiTools
  4. 1 module Compose
  5. 1 module Components
  6. 1 class WebviewComponent
  7. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  8. 7 required_imports&.add(:webview)
  9. # WebView uses 'url' for the web page URL
  10. 7 url = if json_data['url'] && json_data['url'].match(/@\{([^}]+)\}/)
  11. 1 variable = $1
  12. 1 "data.#{variable}"
  13. 6 elsif json_data['url']
  14. 1 "\"#{json_data['url']}\""
  15. else
  16. 5 '""'
  17. end
  18. # Generate WebView using AndroidView
  19. 7 code = indent("AndroidView(", depth)
  20. 7 code += "\n" + indent("factory = { context ->", depth + 1)
  21. 7 code += "\n" + indent("WebView(context).apply {", depth + 2)
  22. # WebView settings
  23. 7 code += "\n" + indent("settings.javaScriptEnabled = #{json_data['javaScriptEnabled'] != false}", depth + 3)
  24. 7 if json_data['userAgent']
  25. 1 code += "\n" + indent("settings.userAgentString = \"#{json_data['userAgent']}\"", depth + 3)
  26. end
  27. 7 code += "\n" + indent("webViewClient = WebViewClient()", depth + 3)
  28. 7 code += "\n" + indent("webChromeClient = WebChromeClient()", depth + 3)
  29. # Load URL
  30. 7 code += "\n" + indent("loadUrl(#{url})", depth + 3)
  31. 7 code += "\n" + indent("}", depth + 2)
  32. 7 code += "\n" + indent("},", depth + 1)
  33. # Build modifiers
  34. 7 modifiers = []
  35. 7 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  36. 7 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  37. 7 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  38. 7 modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
  39. 7 modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
  40. 7 if json_data['cornerRadius']
  41. 1 required_imports&.add(:shape)
  42. 1 modifiers << ".clip(RoundedCornerShape(#{json_data['cornerRadius']}.dp))"
  43. end
  44. 7 code += Helpers::ModifierBuilder.format(modifiers, depth)
  45. 7 code += "\n" + indent(")", depth)
  46. 7 code
  47. end
  48. 1 private
  49. 1 def self.indent(text, level)
  50. 71 return text if level == 0
  51. 57 spaces = ' ' * level
  52. 57 text.split("\n").map { |line|
  53. 57 line.empty? ? line : spaces + line
  54. }.join("\n")
  55. end
  56. end
  57. end
  58. end
  59. end

lib/compose/compose_builder.rb

56.78% lines covered

546 relevant lines. 310 lines covered and 236 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'json'
  3. 1 require 'fileutils'
  4. 1 require 'set'
  5. 1 require_relative '../core/config_manager'
  6. 1 require_relative '../core/project_finder'
  7. 1 require_relative '../core/logger'
  8. 1 require_relative '../core/type_converter'
  9. 1 require_relative 'style_loader'
  10. 1 require_relative 'data_model_updater'
  11. 1 require_relative 'helpers/import_manager'
  12. 1 require_relative 'helpers/modifier_builder'
  13. 1 require_relative 'helpers/resource_resolver'
  14. 1 require_relative 'components/text_component'
  15. 1 require_relative 'components/button_component'
  16. 1 require_relative 'components/textfield_component'
  17. 1 require_relative 'components/container_component'
  18. 1 require_relative 'components/image_component'
  19. 1 require_relative 'components/scrollview_component'
  20. 1 require_relative 'components/switch_component'
  21. 1 require_relative 'components/slider_component'
  22. 1 require_relative 'components/progress_component'
  23. 1 require_relative 'components/selectbox_component'
  24. 1 require_relative 'components/checkbox_component'
  25. 1 require_relative 'components/radio_component'
  26. 1 require_relative 'components/segment_component'
  27. 1 require_relative 'components/networkimage_component'
  28. 1 require_relative 'components/circleimage_component'
  29. 1 require_relative 'components/indicator_component'
  30. 1 require_relative 'components/textview_component'
  31. 1 require_relative 'components/collection_component'
  32. 1 require_relative 'components/table_component'
  33. 1 require_relative 'components/web_component'
  34. 1 require_relative 'components/gradientview_component'
  35. 1 require_relative 'components/blurview_component'
  36. 1 require_relative 'components/tabview_component'
  37. 1 module KjuiTools
  38. 1 module Compose
  39. # Refactored ComposeBuilder - under 300 lines
  40. 1 class ComposeBuilder
  41. 1 def initialize
  42. 69 @config = Core::ConfigManager.load_config
  43. 69 @source_path = Core::ProjectFinder.get_full_source_path || Dir.pwd
  44. 69 source_directory = @config['source_directory'] || 'src/main'
  45. 69 @layouts_dir = File.join(@source_path, source_directory, @config['layouts_directory'] || 'assets/Layouts')
  46. 69 @view_dir = File.join(@source_path, source_directory, @config['view_directory'] || 'kotlin/views')
  47. 69 @package_name = @config['package_name'] || Core::ProjectFinder.get_package_name || 'com.example.app'
  48. 69 FileUtils.mkdir_p(@view_dir) unless File.exist?(@view_dir)
  49. end
  50. 1 def build(options = {})
  51. # Get all JSON files but exclude Resources folder
  52. 2 json_files = Dir.glob(File.join(@layouts_dir, '**/*.json')).reject do |file|
  53. 1 file.include?('/Resources/')
  54. end
  55. 2 if json_files.empty?
  56. 2 Core::Logger.warn "No JSON files found in #{@layouts_dir}"
  57. 2 return
  58. end
  59. # Update data models first
  60. data_updater = DataModelUpdater.new
  61. data_updater.update_data_models
  62. # Build each JSON file
  63. json_files.each { |file| build_file(file) }
  64. end
  65. 1 def build_file(json_file)
  66. 5 base_name = File.basename(json_file, '.json')
  67. 5 snake_case_name = to_snake_case(base_name)
  68. 5 pascal_case_name = to_pascal_case(base_name)
  69. begin
  70. 5 json_content = File.read(json_file)
  71. 5 json_data = JSON.parse(json_content)
  72. 4 json_data = StyleLoader.load_and_merge(json_data)
  73. 4 @required_imports = Set.new
  74. 4 @included_views = Set.new
  75. 4 @custom_components = Set.new
  76. # Collect data definitions for ResourceResolver to check optional/non-optional
  77. 4 data_properties = extract_data_properties(json_data)
  78. 4 data_definitions = {}
  79. 4 data_properties.each do |prop|
  80. data_definitions[prop['name']] = prop
  81. end
  82. 4 Helpers::ResourceResolver.data_definitions = data_definitions
  83. # Find the GeneratedView file - preserve subdirectory structure from layouts
  84. 4 relative_path = json_file.sub(@layouts_dir + '/', '')
  85. 4 relative_dir = File.dirname(relative_path)
  86. 4 if relative_dir == '.'
  87. 4 view_subdir = snake_case_name
  88. else
  89. view_subdir = File.join(relative_dir, snake_case_name)
  90. end
  91. 4 generated_view_file = File.join(@view_dir, view_subdir, "#{pascal_case_name}GeneratedView.kt")
  92. 4 if File.exist?(generated_view_file)
  93. # Calculate the layout name for dynamic mode (relative path without .json)
  94. dynamic_layout_name = relative_path.sub(/\.json$/, '')
  95. update_generated_file(generated_view_file, json_data, dynamic_layout_name)
  96. else
  97. 4 Core::Logger.warn "GeneratedView file not found: #{generated_view_file}"
  98. end
  99. # Update ViewModel's updateData function
  100. 4 source_directory = @config['source_directory'] || 'src/main'
  101. 4 viewmodel_dir = File.join(@source_path, source_directory, @config['viewmodel_directory'] || 'kotlin/viewmodels')
  102. 4 viewmodel_file = File.join(viewmodel_dir, "#{pascal_case_name}ViewModel.kt")
  103. 4 if File.exist?(viewmodel_file)
  104. update_viewmodel_file(viewmodel_file, json_data, pascal_case_name)
  105. else
  106. # Check for cell ViewModels in subdirectories (e.g., viewmodels/Home/WhiskyCardViewModel.kt)
  107. 4 cell_viewmodel_files = Dir.glob(File.join(viewmodel_dir, '**', "#{pascal_case_name}ViewModel.kt"))
  108. 4 cell_viewmodel_files.each do |cell_vm_file|
  109. update_viewmodel_file(cell_vm_file, json_data, pascal_case_name)
  110. end
  111. end
  112. 1 rescue JSON::ParserError => e
  113. 1 Core::Logger.error "Failed to parse #{json_file}: #{e.message}"
  114. rescue => e
  115. Core::Logger.error "Failed to process #{json_file}: #{e.message}"
  116. end
  117. end
  118. 1 private
  119. 1 def generate_component(json_data, depth = 0, parent_type = nil)
  120. 34 return "" unless json_data.is_a?(Hash)
  121. 32 component_type = json_data['type'] || 'View'
  122. # Handle includes
  123. 32 return generate_include(json_data, depth) if json_data['include']
  124. # Generate component based on type
  125. 32 case component_type
  126. when 'ScrollView', 'Scroll'
  127. 2 result = Components::ScrollViewComponent.generate(json_data, depth, @required_imports, parent_type)
  128. 2 handle_container_result(result, depth, parent_type)
  129. when 'SafeAreaView'
  130. generate_safe_area_view(json_data, depth)
  131. when 'View'
  132. 1 result = Components::ContainerComponent.generate(json_data, depth, @required_imports, parent_type)
  133. 1 handle_container_result(result, depth, parent_type)
  134. when 'Text', 'Label'
  135. 5 Components::TextComponent.generate(json_data, depth, @required_imports, parent_type)
  136. when 'Button'
  137. 1 Components::ButtonComponent.generate(json_data, depth, @required_imports, parent_type)
  138. when 'Image'
  139. 1 Components::ImageComponent.generate(json_data, depth, @required_imports, parent_type)
  140. when 'TextField'
  141. 1 Components::TextFieldComponent.generate(json_data, depth, @required_imports, parent_type)
  142. when 'Switch', 'Toggle'
  143. 2 Components::SwitchComponent.generate(json_data, depth, @required_imports, parent_type)
  144. when 'Slider'
  145. 1 Components::SliderComponent.generate(json_data, depth, @required_imports, parent_type)
  146. when 'Progress'
  147. 1 Components::ProgressComponent.generate(json_data, depth, @required_imports, parent_type)
  148. when 'SelectBox'
  149. 1 Components::SelectBoxComponent.generate(json_data, depth, @required_imports, parent_type)
  150. when 'Check', 'Checkbox', 'CheckBox'
  151. 2 Components::CheckboxComponent.generate(json_data, depth, @required_imports, parent_type)
  152. when 'Radio'
  153. 1 Components::RadioComponent.generate(json_data, depth, @required_imports, parent_type)
  154. when 'Segment'
  155. 1 Components::SegmentComponent.generate(json_data, depth, @required_imports, parent_type)
  156. when 'NetworkImage'
  157. 1 Components::NetworkImageComponent.generate(json_data, depth, @required_imports, parent_type)
  158. when 'CircleImage'
  159. 1 Components::CircleImageComponent.generate(json_data, depth, @required_imports, parent_type)
  160. when 'Indicator'
  161. 1 Components::IndicatorComponent.generate(json_data, depth, @required_imports, parent_type)
  162. when 'TextView'
  163. 1 Components::TextViewComponent.generate(json_data, depth, @required_imports, parent_type)
  164. when 'Collection'
  165. 1 Components::CollectionComponent.generate(json_data, depth, @required_imports, parent_type)
  166. when 'Table'
  167. 1 Components::TableComponent.generate(json_data, depth, @required_imports, parent_type)
  168. when 'Web'
  169. 1 Components::WebComponent.generate(json_data, depth, @required_imports, parent_type)
  170. when 'GradientView'
  171. 1 result = Components::GradientviewComponent.generate(json_data, depth, @required_imports, parent_type)
  172. 1 handle_container_result(result, depth, parent_type)
  173. when 'BlurView'
  174. 1 result = Components::BlurviewComponent.generate(json_data, depth, @required_imports, parent_type)
  175. 1 handle_container_result(result, depth, parent_type)
  176. when 'TabView'
  177. result = Components::TabviewComponent.generate(json_data, depth, @required_imports, parent_type)
  178. handle_container_result(result, depth, parent_type)
  179. when 'Spacer'
  180. 2 "Spacer(modifier = Modifier.height(#{json_data['height'] || 8}.dp))"
  181. else
  182. # Check for custom components
  183. 1 check_custom_component(component_type, json_data, depth, parent_type)
  184. end
  185. end
  186. 1 def check_custom_component(component_type, json_data, depth, parent_type)
  187. # Try to load custom component mappings if they exist
  188. 1 mappings_file = File.join(File.dirname(__FILE__), 'components', 'extensions', 'component_mappings.rb')
  189. 1 if File.exist?(mappings_file)
  190. require_relative 'components/extensions/component_mappings'
  191. if defined?(Components::Extensions::COMPONENT_MAPPINGS)
  192. component_class = Components::Extensions::COMPONENT_MAPPINGS[component_type]
  193. if component_class
  194. # Load the custom component file
  195. snake_case_name = component_type.gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2')
  196. .gsub(/([a-z\d])([A-Z])/,'\1_\2')
  197. .downcase
  198. component_file = File.join(File.dirname(__FILE__), 'components', 'extensions', "#{snake_case_name}_component.rb")
  199. if File.exist?(component_file)
  200. require_relative "components/extensions/#{snake_case_name}_component"
  201. # Add import for the custom component
  202. @custom_components&.add(component_type)
  203. result = component_class.generate(json_data, depth, @required_imports, parent_type)
  204. # Handle container components that return metadata
  205. if result.is_a?(Hash) && result[:children]
  206. return handle_container_result(result, depth, parent_type)
  207. else
  208. return result
  209. end
  210. end
  211. end
  212. end
  213. end
  214. 1 "// TODO: Implement component type: #{component_type}"
  215. end
  216. 1 def handle_container_result(result, depth, parent_type = nil)
  217. 7 if result.is_a?(Hash)
  218. 6 code = result[:code]
  219. 6 children = result[:children] || []
  220. 6 layout_type = result[:layout_type] || parent_type
  221. 6 json_data = result[:json_data]
  222. # Add lifecycle effects at the start of container content
  223. 6 if json_data && Helpers::ModifierBuilder.has_lifecycle_events?(json_data)
  224. lifecycle = Helpers::ModifierBuilder.build_lifecycle_effects(json_data, depth + 1, @required_imports)
  225. code += "\n" + lifecycle[:before] unless lifecycle[:before].empty?
  226. end
  227. 6 children.each do |child|
  228. 1 child_code = generate_component(child, depth + 1, layout_type)
  229. 1 code += "\n" + child_code unless child_code.empty?
  230. end
  231. 6 code += result[:closing] if result[:closing]
  232. 6 code
  233. else
  234. 1 result
  235. end
  236. end
  237. 1 def generate_safe_area_view(json_data, depth)
  238. # Add import for SafeAreaConfig
  239. 3 @required_imports&.add(:safe_area_config)
  240. # Parse edges - support both 'edges' and 'safeAreaInsetPositions' (alias)
  241. 3 edges_array = json_data['edges'] || json_data['safeAreaInsetPositions'] || ['all']
  242. 3 edges = edges_array.is_a?(Array) ? edges_array : [edges_array]
  243. # Parse orientation for child layout
  244. 3 orientation = json_data['orientation']
  245. # Determine container type based on orientation
  246. # No orientation = Box (like ZStack in SwiftUI)
  247. 3 container = case orientation
  248. when 'horizontal' then 'Row'
  249. when 'vertical' then 'Column'
  250. 3 else 'Box'
  251. end
  252. # Get parent SafeAreaConfig and filter edges
  253. 3 code = indent("val safeAreaConfig = LocalSafeAreaConfig.current", depth)
  254. 6 code += "\n" + indent("val edges = mutableListOf(#{edges.map { |e| "\"#{e}\"" }.join(', ')}).apply {", depth)
  255. 3 code += "\n" + indent("if (safeAreaConfig.ignoreBottom) {", depth + 1)
  256. 3 code += "\n" + indent("remove(\"bottom\")", depth + 2)
  257. 3 code += "\n" + indent("if (contains(\"all\")) { remove(\"all\"); addAll(listOf(\"top\", \"start\", \"end\")) }", depth + 2)
  258. 3 code += "\n" + indent("}", depth + 1)
  259. 3 code += "\n" + indent("if (safeAreaConfig.ignoreTop) {", depth + 1)
  260. 3 code += "\n" + indent("remove(\"top\")", depth + 2)
  261. 3 code += "\n" + indent("if (contains(\"all\")) { remove(\"all\"); addAll(listOf(\"bottom\", \"start\", \"end\")) }", depth + 2)
  262. 3 code += "\n" + indent("}", depth + 1)
  263. 3 code += "\n" + indent("}.distinct()", depth)
  264. 3 code += "\n\n" + indent("#{container}(", depth)
  265. # Build modifiers
  266. # Background must come BEFORE systemBarsPadding so it extends to screen edges
  267. 3 modifiers = ["Modifier"]
  268. 3 modifiers << ".fillMaxWidth()"
  269. 3 modifiers.concat(Helpers::ModifierBuilder.build_background(json_data, @required_imports))
  270. # Apply safe area padding based on edges (after background)
  271. # Use conditional modifiers based on runtime edges
  272. 3 modifiers << ".then(if (edges.contains(\"all\")) Modifier.systemBarsPadding() else Modifier)"
  273. 3 modifiers << ".then(if (!edges.contains(\"all\") && edges.contains(\"top\")) Modifier.statusBarsPadding() else Modifier)"
  274. 3 modifiers << ".then(if (!edges.contains(\"all\") && edges.contains(\"bottom\")) Modifier.navigationBarsPadding() else Modifier)"
  275. # Check if keyboard padding should be applied
  276. 3 ignore_keyboard = json_data['ignoreKeyboard'] == true
  277. 3 modifiers << ".imePadding()" unless ignore_keyboard
  278. 3 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  279. 3 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  280. 3 code += Helpers::ModifierBuilder.format(modifiers, depth)
  281. 3 code += "\n" + indent(") {", depth)
  282. # Get children - support both 'child' and 'children'
  283. 3 children = json_data['children'] || json_data['child'] || []
  284. 3 children = [children] unless children.is_a?(Array)
  285. 3 children.each do |child|
  286. 2 child_code = generate_component(child, depth + 1)
  287. 2 code += "\n" + child_code unless child_code.empty?
  288. end
  289. 3 code += "\n" + indent("}", depth)
  290. 3 code
  291. end
  292. 1 def generate_include(json_data, depth)
  293. 5 include_name = json_data['include']
  294. 5 pascal_name = to_pascal_case(include_name)
  295. 5 snake_name = to_snake_case(include_name)
  296. # Check if we should use DynamicView
  297. 5 use_dynamic = json_data['dynamic'] == true
  298. # Track this included view for imports
  299. 5 @included_views&.add(snake_name) unless use_dynamic
  300. # Track required imports for LaunchedEffect if we have data bindings
  301. 5 has_data_bindings = false
  302. # Check if there's data or shared_data to pass
  303. 5 include_data = json_data['data'] || {}
  304. 5 shared_data = json_data['shared_data'] || {}
  305. # Check for @{} bindings in data
  306. 5 include_data.each do |key, value|
  307. 1 if value.is_a?(String) && value.match(/@\{([^}]+)\}/)
  308. 1 has_data_bindings = true
  309. 1 unless use_dynamic
  310. 1 @required_imports.add(:LaunchedEffect)
  311. 1 @required_imports.add(:remember)
  312. end
  313. 1 break
  314. end
  315. end
  316. # If using dynamic view, generate DynamicView call
  317. 5 if use_dynamic
  318. 1 return generate_dynamic_include(json_data, depth, include_data, shared_data, has_data_bindings)
  319. end
  320. # Generate unique instance ID for this include
  321. 4 instance_id = "#{to_camel_case(include_name)}Instance#{depth}"
  322. 4 code = ""
  323. # Create a remember block for the ViewModel instance
  324. 4 code += indent("val context = LocalContext.current", depth)
  325. 4 code += "\n"
  326. 4 code += indent("val #{instance_id} = remember { #{pascal_name}ViewModel(context.applicationContext as Application) }", depth)
  327. 4 code += "\n"
  328. # If we have data bindings, add LaunchedEffect to update on parent data changes
  329. 4 if has_data_bindings || shared_data.any?
  330. 2 code += "\n" + indent("// Update included view when parent data changes", depth)
  331. 2 code += "\n" + indent("LaunchedEffect(", depth)
  332. # Add keys for all bound variables
  333. 2 keys = []
  334. 2 include_data.each do |key, value|
  335. 1 if value.is_a?(String) && value.match(/@\{([^}]+)\}/)
  336. 1 variable = $1
  337. 1 keys << "data.#{variable}"
  338. end
  339. end
  340. 2 shared_data.each do |key, value|
  341. 1 if value.is_a?(String) && value.match(/@\{([^}]+)\}/)
  342. 1 variable = $1
  343. 1 keys << "data.#{variable}"
  344. end
  345. end
  346. 2 if keys.any?
  347. 2 code += keys.join(", ")
  348. else
  349. code += "Unit"
  350. end
  351. 2 code += ") {"
  352. 2 code += "\n" + indent("val updates = mutableMapOf<String, Any>()", depth + 1)
  353. # Process data (one-way binding from parent to child)
  354. 2 include_data.each do |key, value|
  355. 1 if value.is_a?(String) && value.match(/@\{([^}]+)\}/)
  356. # This is a data binding reference to parent data
  357. 1 variable = $1
  358. 1 code += "\n" + indent("updates[\"#{key}\"] = data.#{variable}", depth + 1)
  359. else
  360. # This is a static value
  361. formatted_value = format_value_for_kotlin(value)
  362. code += "\n" + indent("updates[\"#{key}\"] = #{formatted_value}", depth + 1)
  363. end
  364. end
  365. # Process shared_data (two-way binding)
  366. 2 if shared_data.any?
  367. 1 code += "\n" + indent("// Shared data for two-way binding", depth + 1)
  368. 1 shared_data.each do |key, value|
  369. 1 if value.is_a?(String) && value.match(/@\{([^}]+)\}/)
  370. # This creates a two-way binding
  371. 1 variable = $1
  372. 1 code += "\n" + indent("updates[\"#{key}\"] = data.#{variable}", depth + 1)
  373. # TODO: Also need to update parent when child changes
  374. else
  375. # Static value for shared_data
  376. formatted_value = format_value_for_kotlin(value)
  377. code += "\n" + indent("updates[\"#{key}\"] = #{formatted_value}", depth + 1)
  378. end
  379. end
  380. end
  381. 2 code += "\n" + indent("#{instance_id}.updateData(updates)", depth + 1)
  382. 2 code += "\n" + indent("}", depth)
  383. end
  384. # Generate the included view call
  385. 4 code += "\n" + indent("#{pascal_name}View(", depth)
  386. 4 code += "\n" + indent("viewModel = #{instance_id}", depth + 1)
  387. 4 code += "\n" + indent(")", depth)
  388. 4 code
  389. end
  390. 1 def generate_dynamic_include(json_data, depth, include_data, shared_data, has_data_bindings)
  391. 1 include_name = json_data['include']
  392. # Add required imports for SafeDynamicView
  393. 1 @required_imports.add(:safe_dynamic_view)
  394. 1 code = ""
  395. # Build data map from bindings and current data
  396. 1 code += indent("// Build data map with bindings", depth)
  397. 1 code += "\n" + indent("val dynamicData = mutableMapOf<String, Any>()", depth)
  398. # Add all current data values
  399. 1 code += "\n" + indent("// Add current data values", depth)
  400. 1 code += "\n" + indent("data.forEach { (key, value) ->", depth)
  401. 1 code += "\n" + indent("dynamicData[key] = value", depth + 1)
  402. 1 code += "\n" + indent("}", depth)
  403. # Process include_data bindings
  404. 1 if include_data.any?
  405. code += "\n" + indent("// Process include data bindings", depth)
  406. include_data.each do |key, value|
  407. if value.is_a?(String) && value.match(/@\{([^}]+)\}/)
  408. # This is a data binding reference to parent data
  409. variable = $1
  410. code += "\n" + indent("data[\"#{variable}\"]?.let { dynamicData[\"#{key}\"] = it }", depth)
  411. else
  412. # This is a static value
  413. formatted_value = format_value_for_kotlin(value)
  414. code += "\n" + indent("dynamicData[\"#{key}\"] = #{formatted_value}", depth)
  415. end
  416. end
  417. end
  418. # Process shared_data bindings
  419. 1 if shared_data.any?
  420. code += "\n" + indent("// Process shared data bindings", depth)
  421. shared_data.each do |key, value|
  422. if value.is_a?(String) && value.match(/@\{([^}]+)\}/)
  423. # This creates a two-way binding
  424. variable = $1
  425. code += "\n" + indent("data[\"#{variable}\"]?.let { dynamicData[\"#{key}\"] = it }", depth)
  426. else
  427. # Static value for shared_data
  428. formatted_value = format_value_for_kotlin(value)
  429. code += "\n" + indent("dynamicData[\"#{key}\"] = #{formatted_value}", depth)
  430. end
  431. end
  432. end
  433. # Add all viewModel methods as functions to the data map
  434. 1 code += "\n" + indent("// Add viewModel methods as event handlers", depth)
  435. 1 code += "\n" + indent("// Note: Add specific method references as needed", depth)
  436. 1 code += "\n" + indent("// Example: dynamicData[\"onButtonClick\"] = { viewModel.onButtonClick() }", depth)
  437. # Call SafeDynamicView
  438. 1 code += "\n" + indent("// Render dynamic view", depth)
  439. 1 code += "\n" + indent("SafeDynamicView(", depth)
  440. 1 code += "\n" + indent("layoutName = \"#{include_name}\",", depth + 1)
  441. 1 code += "\n" + indent("data = dynamicData", depth + 1)
  442. 1 code += "\n" + indent(")", depth)
  443. 1 code
  444. end
  445. 1 def format_value_for_kotlin(value)
  446. 7 case value
  447. when String
  448. 1 "\"#{value.gsub('"', '\\"')}\""
  449. when Integer
  450. 1 value.to_s
  451. when Float
  452. 1 "#{value}f"
  453. when TrueClass, FalseClass
  454. 2 value.to_s
  455. when nil
  456. 1 "null"
  457. else
  458. 1 "\"#{value}\""
  459. end
  460. end
  461. 1 def update_generated_file(file_path, json_data, dynamic_layout_name = nil)
  462. existing_content = File.read(file_path)
  463. if existing_content.include?('// >>> GENERATED_CODE_START') &&
  464. existing_content.include?('// >>> GENERATED_CODE_END')
  465. # Use the provided dynamic layout name or extract from file path as fallback
  466. layout_name = dynamic_layout_name || File.basename(File.dirname(file_path))
  467. view_name = to_pascal_case(File.basename(layout_name))
  468. # Generate both static and dynamic versions
  469. static_content = generate_component(json_data, 1)
  470. dynamic_content = generate_dynamic_view_content(layout_name, json_data, 1)
  471. # Create content that switches based on DynamicModeManager
  472. composable_content = generate_mode_aware_content(layout_name, static_content, dynamic_content, 1)
  473. updated_content = existing_content.gsub(
  474. /\/\/ >>> GENERATED_CODE_START.*?\/\/ >>> GENERATED_CODE_END/m,
  475. "// >>> GENERATED_CODE_START\n#{composable_content} // >>> GENERATED_CODE_END"
  476. )
  477. # Update function signature to include viewModel parameter
  478. updated_content = updated_content.gsub(
  479. /fun #{view_name}GeneratedView\(\s*\n\s*data: #{view_name}Data\s*\n\s*\)/m,
  480. "fun #{view_name}GeneratedView(\n data: #{view_name}Data,\n viewModel: #{view_name}ViewModel\n)"
  481. )
  482. # Add ViewModel import if not present
  483. viewmodel_import = "import #{@package_name}.viewmodels.#{view_name}ViewModel"
  484. unless updated_content.include?(viewmodel_import)
  485. # Add after Data import
  486. data_import = "import #{@package_name}.data.#{view_name}Data"
  487. updated_content = updated_content.gsub(data_import, "#{data_import}\n#{viewmodel_import}")
  488. end
  489. updated_content = update_imports(updated_content)
  490. File.write(file_path, updated_content)
  491. Core::Logger.success "Updated: #{file_path}"
  492. else
  493. Core::Logger.warn "Generated code markers not found in #{file_path}"
  494. end
  495. end
  496. 1 def update_viewmodel_file(file_path, json_data, view_name)
  497. existing_content = File.read(file_path)
  498. # Check if the file has generated code markers
  499. unless existing_content.include?('// >>> GENERATED_CODE_START') &&
  500. existing_content.include?('// >>> GENERATED_CODE_END')
  501. return # Skip files without markers
  502. end
  503. # Extract data properties from JSON
  504. data_properties = extract_data_properties(json_data)
  505. # Generate the updateData function content
  506. update_data_content = generate_update_data_function(data_properties, view_name)
  507. # Replace the generated section
  508. updated_content = existing_content.gsub(
  509. /\/\/ >>> GENERATED_CODE_START.*?\/\/ >>> GENERATED_CODE_END/m,
  510. "// >>> GENERATED_CODE_START\n#{update_data_content} // >>> GENERATED_CODE_END"
  511. )
  512. # Add kotlinx.coroutines.flow.update import if not present
  513. update_import = "import kotlinx.coroutines.flow.update"
  514. unless updated_content.include?(update_import)
  515. # Add after asStateFlow import
  516. as_state_flow_import = "import kotlinx.coroutines.flow.asStateFlow"
  517. if updated_content.include?(as_state_flow_import)
  518. updated_content = updated_content.gsub(as_state_flow_import, "#{as_state_flow_import}\n#{update_import}")
  519. end
  520. end
  521. # Add Painter import if any property uses Image type
  522. if data_properties.any? { |prop| prop['class'] == 'Image' || prop['class'] == 'Painter' }
  523. painter_import = "import androidx.compose.ui.graphics.painter.Painter"
  524. unless updated_content.include?(painter_import)
  525. # Add after package line
  526. updated_content = updated_content.sub(/^(package .+\n)/, "\\1\n#{painter_import}\n")
  527. end
  528. end
  529. # Add Color import if any property uses Color type
  530. if data_properties.any? { |prop| prop['class'] == 'Color' }
  531. color_import = "import androidx.compose.ui.graphics.Color"
  532. unless updated_content.include?(color_import)
  533. # Add after package line
  534. updated_content = updated_content.sub(/^(package .+\n)/, "\\1\n#{color_import}\n")
  535. end
  536. end
  537. File.write(file_path, updated_content)
  538. Core::Logger.success "Updated ViewModel: #{file_path}"
  539. end
  540. 1 def extract_data_properties(json_data, properties = [])
  541. 7 if json_data.is_a?(Hash)
  542. # Stop if this is an include - includes have their own data models
  543. 7 return properties if json_data['include']
  544. # Check for data section at any level, but only process the first one found
  545. 7 if json_data['data'] && properties.empty?
  546. if json_data['data'].is_a?(Array)
  547. json_data['data'].each do |data_item|
  548. if data_item.is_a?(Hash) && data_item['name']
  549. unless properties.any? { |p| p['name'] == data_item['name'] }
  550. properties << data_item
  551. end
  552. end
  553. end
  554. end
  555. end
  556. # If we haven't found data yet, continue searching in children
  557. 7 if properties.empty? && json_data['child']
  558. 3 if json_data['child'].is_a?(Array)
  559. 3 json_data['child'].each do |child|
  560. 3 extract_data_properties(child, properties)
  561. 3 break unless properties.empty?
  562. end
  563. else
  564. extract_data_properties(json_data['child'], properties)
  565. end
  566. end
  567. elsif json_data.is_a?(Array)
  568. json_data.each do |item|
  569. extract_data_properties(item, properties)
  570. break unless properties.empty?
  571. end
  572. end
  573. 7 properties
  574. end
  575. 1 def generate_update_data_function(data_properties, view_name)
  576. code = " // Auto-generated updateData function - updated by 'kjui build'\n"
  577. # Add @Suppress("UNCHECKED_CAST") if there are callback properties
  578. has_callback_properties = data_properties.any? { |prop|
  579. class_type = prop['class'].to_s
  580. class_type.include?('-> Unit') || class_type.include?('-> Void')
  581. }
  582. if has_callback_properties
  583. code += " @Suppress(\"UNCHECKED_CAST\")\n"
  584. end
  585. code += " fun updateData(updates: Map<String, Any>) {\n"
  586. code += " _data.update { current ->\n"
  587. code += " var updated = current\n"
  588. code += " updates.forEach { (key, value) ->\n"
  589. code += " updated = when (key) {\n"
  590. if data_properties.empty?
  591. code += " else -> updated\n"
  592. else
  593. data_properties.each do |prop|
  594. name = prop['name']
  595. class_type = prop['class'] || 'String'
  596. kotlin_cast = get_kotlin_cast(class_type, name)
  597. code += " \"#{name}\" -> updated.copy(#{name} = #{kotlin_cast})\n"
  598. end
  599. code += " else -> updated\n"
  600. end
  601. code += " }\n"
  602. code += " }\n"
  603. code += " updated\n"
  604. code += " }\n"
  605. code += " }\n"
  606. code
  607. end
  608. 1 def get_kotlin_cast(class_type, name)
  609. # Convert Swift types to Kotlin types using TypeConverter
  610. kotlin_type = Core::TypeConverter.to_kotlin_type(class_type, @mode)
  611. case class_type
  612. when 'String'
  613. "value as? String ?: updated.#{name}"
  614. when 'Int'
  615. "(value as? Number)?.toInt() ?: updated.#{name}"
  616. when 'Double'
  617. "(value as? Number)?.toDouble() ?: updated.#{name}"
  618. when 'Float', 'CGFloat'
  619. "(value as? Number)?.toFloat() ?: updated.#{name}"
  620. when 'Bool', 'Boolean'
  621. "value as? Boolean ?: updated.#{name}"
  622. when 'Image', 'Painter'
  623. "value as? Painter ?: updated.#{name}"
  624. when 'Color'
  625. "value as? Color ?: updated.#{name}"
  626. else
  627. "value as? #{kotlin_type} ?: updated.#{name}"
  628. end
  629. end
  630. 1 def generate_mode_aware_content(layout_name, static_content, dynamic_content, depth)
  631. indent_str = " " * depth
  632. code = ""
  633. code += "#{indent_str}// Check if Dynamic Mode is active\n"
  634. code += "#{indent_str}if (DynamicModeManager.isActive()) {\n"
  635. code += "#{indent_str} // Dynamic Mode - use SafeDynamicView for real-time updates\n"
  636. code += dynamic_content
  637. code += "#{indent_str}} else {\n"
  638. code += "#{indent_str} // Static Mode - use generated code\n"
  639. code += " #{static_content}"
  640. code += "#{indent_str}}\n"
  641. # Add required imports for DynamicModeManager
  642. @required_imports.add(:dynamic_mode_manager)
  643. # SafeDynamicView import is already added in generate_dynamic_view
  644. code
  645. end
  646. 1 def generate_dynamic_view_content(layout_name, json_data, depth)
  647. indent_str = " " * depth
  648. code = ""
  649. code += "#{indent_str} SafeDynamicView(\n"
  650. code += "#{indent_str} layoutName = \"#{layout_name}\",\n"
  651. code += "#{indent_str} data = data.toMap(),\n"
  652. code += "#{indent_str} fallback = {\n"
  653. code += "#{indent_str} // Show error or loading state when dynamic view is not available\n"
  654. code += "#{indent_str} Box(\n"
  655. code += "#{indent_str} modifier = Modifier.fillMaxSize(),\n"
  656. code += "#{indent_str} contentAlignment = Alignment.Center\n"
  657. code += "#{indent_str} ) {\n"
  658. code += "#{indent_str} Text(\n"
  659. code += "#{indent_str} text = \"Dynamic view not available\",\n"
  660. code += "#{indent_str} color = Color.Gray\n"
  661. code += "#{indent_str} )\n"
  662. code += "#{indent_str} }\n"
  663. code += "#{indent_str} },\n"
  664. code += "#{indent_str} onError = { error ->\n"
  665. code += "#{indent_str} // Log error or show error UI\n"
  666. code += "#{indent_str} android.util.Log.e(\"DynamicView\", \"Error loading #{layout_name}: \\$error\")\n"
  667. code += "#{indent_str} },\n"
  668. code += "#{indent_str} onLoading = {\n"
  669. code += "#{indent_str} // Show loading indicator\n"
  670. code += "#{indent_str} Box(\n"
  671. code += "#{indent_str} modifier = Modifier.fillMaxSize(),\n"
  672. code += "#{indent_str} contentAlignment = Alignment.Center\n"
  673. code += "#{indent_str} ) {\n"
  674. code += "#{indent_str} CircularProgressIndicator()\n"
  675. code += "#{indent_str} }\n"
  676. code += "#{indent_str} }\n"
  677. code += "#{indent_str} ) { jsonContent ->\n"
  678. code += "#{indent_str} // Parse and render the dynamic JSON content\n"
  679. code += "#{indent_str} // This will be handled by the DynamicView implementation\n"
  680. code += "#{indent_str} }\n"
  681. # Add required imports
  682. @required_imports.add(:safe_dynamic_view)
  683. @required_imports.add(:circular_progress_indicator)
  684. @required_imports.add(:box)
  685. code
  686. end
  687. 1 def update_imports(content)
  688. 1 imports_map = Helpers::ImportManager.get_imports_map(@package_name)
  689. 1 imports_to_add = []
  690. 1 @required_imports.each do |import_type|
  691. 1 import_lines = imports_map[import_type]
  692. 1 if import_lines
  693. if import_lines.is_a?(Array)
  694. imports_to_add.concat(import_lines)
  695. else
  696. imports_to_add << import_lines
  697. end
  698. end
  699. end
  700. # Add imports for included views
  701. 1 if @included_views && @included_views.any?
  702. # Add necessary imports for creating ViewModels
  703. imports_to_add << "import android.app.Application" unless imports_to_add.include?("import android.app.Application")
  704. imports_to_add << "import androidx.compose.ui.platform.LocalContext" unless imports_to_add.include?("import androidx.compose.ui.platform.LocalContext")
  705. @included_views.each do |view_name|
  706. pascal_name = to_pascal_case(view_name)
  707. view_import = "import #{@package_name}.views.#{view_name}.#{pascal_name}View"
  708. data_import = "import #{@package_name}.data.#{pascal_name}Data"
  709. viewmodel_import = "import #{@package_name}.viewmodels.#{pascal_name}ViewModel"
  710. imports_to_add << view_import unless imports_to_add.include?(view_import)
  711. imports_to_add << data_import unless imports_to_add.include?(data_import)
  712. imports_to_add << viewmodel_import unless imports_to_add.include?(viewmodel_import)
  713. end
  714. end
  715. # Add imports for custom components
  716. 1 if @custom_components && @custom_components.any?
  717. @custom_components.each do |component_name|
  718. component_import = "import #{@package_name}.extensions.#{component_name}"
  719. imports_to_add << component_import unless imports_to_add.include?(component_import)
  720. end
  721. end
  722. # Add imports for cell views (from sections in Collection components)
  723. # Process "cell:CellName" entries from required_imports
  724. 2 cell_imports = @required_imports.select { |imp| imp.to_s.start_with?('cell:') }
  725. 1 if cell_imports.any?
  726. # Add necessary imports for creating ViewModels in collections
  727. imports_to_add << "import androidx.lifecycle.viewmodel.compose.viewModel" unless imports_to_add.include?("import androidx.lifecycle.viewmodel.compose.viewModel")
  728. cell_imports.each do |cell_import|
  729. # Extract cell class name from "cell:CellName"
  730. cell_class = cell_import.to_s.sub('cell:', '')
  731. snake_name = to_snake_case(cell_class)
  732. # Find the cell's subdirectory by locating its JSON file
  733. cell_subdir = find_cell_subdirectory(snake_name)
  734. # Build the view import path with subdirectory if found
  735. if cell_subdir
  736. view_import = "import #{@package_name}.views.#{cell_subdir}.#{snake_name}.#{cell_class}View"
  737. else
  738. view_import = "import #{@package_name}.views.#{snake_name}.#{cell_class}View"
  739. end
  740. data_import = "import #{@package_name}.data.#{cell_class}Data"
  741. viewmodel_import = "import #{@package_name}.viewmodels.#{cell_class}ViewModel"
  742. imports_to_add << view_import unless imports_to_add.include?(view_import)
  743. imports_to_add << data_import unless imports_to_add.include?(data_import)
  744. imports_to_add << viewmodel_import unless imports_to_add.include?(viewmodel_import)
  745. end
  746. end
  747. # Add imports for TabView tab views
  748. # Process "tabview:ViewName" entries from required_imports
  749. 2 tabview_imports = @required_imports.select { |imp| imp.to_s.start_with?('tabview:') }
  750. 1 if tabview_imports.any?
  751. tabview_imports.each do |tabview_import|
  752. # Extract view class name from "tabview:ViewName"
  753. view_class = tabview_import.to_s.sub('tabview:', '')
  754. snake_name = to_snake_case(view_class)
  755. # Find the view's subdirectory by locating its JSON file
  756. view_subdir = find_cell_subdirectory(snake_name)
  757. # Build the view import path with subdirectory if found
  758. if view_subdir
  759. view_import = "import #{@package_name}.views.#{view_subdir}.#{snake_name}.#{view_class}View"
  760. else
  761. view_import = "import #{@package_name}.views.#{snake_name}.#{view_class}View"
  762. end
  763. imports_to_add << view_import unless imports_to_add.include?(view_import)
  764. end
  765. end
  766. 1 if imports_to_add.any?
  767. lines = content.split("\n")
  768. package_index = lines.find_index { |line| line.start_with?("package ") }
  769. if package_index
  770. last_import_index = lines.each_with_index.select { |line, i|
  771. i > package_index && line.start_with?("import ")
  772. }.map(&:last).max || package_index
  773. imports_to_add.each do |import|
  774. unless lines.any? { |line| line == import }
  775. lines.insert(last_import_index + 1, import)
  776. last_import_index += 1
  777. end
  778. end
  779. content = lines.join("\n")
  780. end
  781. end
  782. 1 content
  783. end
  784. 1 def process_data_binding(text)
  785. 3 return quote(text) unless text.is_a?(String)
  786. 3 if text.match(/@\{([^}]+)\}/)
  787. 2 variable = $1
  788. 2 if variable.include?(' ?? ')
  789. 1 var_name = variable.split(' ?? ')[0].strip
  790. 1 "\"\${data.#{var_name}}\""
  791. else
  792. 1 "\"\${data.#{variable}}\""
  793. end
  794. else
  795. 1 quote(text)
  796. end
  797. end
  798. 1 def quote(text)
  799. # Escape special characters properly
  800. 4 escaped = text.gsub('\\', '\\\\\\\\') # Escape backslashes first
  801. .gsub('"', '\\"') # Escape quotes
  802. .gsub("\n", '\\n') # Escape newlines
  803. .gsub("\r", '\\r') # Escape carriage returns
  804. .gsub("\t", '\\t') # Escape tabs
  805. 4 "\"#{escaped}\""
  806. end
  807. 1 def indent(text, level)
  808. 91 return text if level == 0
  809. 39 spaces = ' ' * level
  810. 39 text.split("\n").map { |line|
  811. 39 line.empty? ? line : spaces + line
  812. }.join("\n")
  813. end
  814. 1 def to_pascal_case(str)
  815. 17 str.split(/[_\-]/).map(&:capitalize).join
  816. end
  817. 1 def to_camel_case(str)
  818. 5 pascal = to_pascal_case(str)
  819. 5 pascal[0].downcase + pascal[1..-1]
  820. end
  821. 1 def to_snake_case(str)
  822. 11 str.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
  823. .gsub(/([a-z\d])([A-Z])/, '\1_\2')
  824. .downcase
  825. end
  826. # Find the subdirectory where a cell's JSON file is located
  827. # Returns the subdirectory path in dot notation (e.g., "home" for home/whisky_card.json)
  828. # Returns nil if the cell is in the root Layouts directory
  829. 1 def find_cell_subdirectory(cell_snake_name)
  830. # Search for the cell's JSON file in the layouts directory
  831. json_files = Dir.glob(File.join(@layouts_dir, '**', "#{cell_snake_name}.json"))
  832. return nil if json_files.empty?
  833. # Get the first match and extract its relative path
  834. json_file = json_files.first
  835. relative_path = json_file.sub(@layouts_dir + '/', '')
  836. dir_path = File.dirname(relative_path)
  837. return nil if dir_path == '.'
  838. # Convert directory path to dot notation and ensure snake_case
  839. dir_path.split('/').map { |p| to_snake_case(p) }.join('.')
  840. end
  841. end
  842. end
  843. end

lib/compose/data_model_updater.rb

86.06% lines covered

251 relevant lines. 216 lines covered and 35 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'json'
  3. 1 require 'fileutils'
  4. 1 require 'set'
  5. 1 require_relative '../core/config_manager'
  6. 1 require_relative '../core/project_finder'
  7. 1 require_relative '../core/type_converter'
  8. 1 require_relative 'style_loader'
  9. 1 module KjuiTools
  10. 1 module Compose
  11. 1 class DataModelUpdater
  12. 1 def initialize
  13. 52 @config = Core::ConfigManager.load_config
  14. 52 @source_path = Core::ProjectFinder.get_full_source_path || Dir.pwd
  15. 52 source_directory = @config['source_directory'] || 'src/main'
  16. 52 @layouts_dir = File.join(@source_path, source_directory, @config['layouts_directory'] || 'assets/Layouts')
  17. 52 @data_dir = File.join(@source_path, source_directory, @config['data_directory'] || 'kotlin/com/example/kotlinjsonui/sample/data')
  18. 52 @package_name = @config['package_name'] || Core::ProjectFinder.get_package_name || 'com.example.app'
  19. 52 @mode = @config['mode'] || 'compose'
  20. end
  21. 1 def update_data_models(files_to_update = nil)
  22. # If specific files provided, only update those
  23. 11 if files_to_update && !files_to_update.empty?
  24. 3 puts " Updating data models for #{files_to_update.length} modified files..."
  25. 3 files_to_update.each do |json_file|
  26. 3 process_json_file(json_file)
  27. end
  28. else
  29. # Process all JSON files in Layouts directory but exclude Resources and Styles folders
  30. 8 json_files = Dir.glob(File.join(@layouts_dir, '**/*.json')).reject do |file|
  31. # Skip Resources and Styles folders (styles don't need data models)
  32. 9 file.include?('/Resources/') || file.include?('/Styles/')
  33. end
  34. 8 puts " Updating data models for #{json_files.length} files..."
  35. 8 json_files.each do |json_file|
  36. 8 process_json_file(json_file)
  37. end
  38. end
  39. end
  40. 1 private
  41. 1 def process_json_file(json_file)
  42. 11 json_content = File.read(json_file)
  43. 11 json_data = JSON.parse(json_content)
  44. # Load and merge styles into the JSON data
  45. 11 json_data = StyleLoader.load_and_merge(json_data)
  46. # Extract data properties from JSON
  47. 11 data_properties = extract_data_properties(json_data)
  48. # Extract onclick actions from JSON (now includes actions from styles)
  49. 11 onclick_actions = extract_onclick_actions(json_data)
  50. # Always create/update data file, even if no properties
  51. # Get the view name from file path
  52. 11 base_name = File.basename(json_file, '.json')
  53. # Update the Data model file
  54. 11 update_data_file(base_name, data_properties, onclick_actions)
  55. end
  56. 1 def extract_onclick_actions(json_data, actions = Set.new)
  57. 29 if json_data.is_a?(Hash)
  58. # Check for onclick attribute
  59. 28 if json_data['onclick'] && json_data['onclick'].is_a?(String)
  60. 8 actions.add(json_data['onclick'])
  61. end
  62. # Process children
  63. 28 if json_data['child']
  64. 10 if json_data['child'].is_a?(Array)
  65. 8 json_data['child'].each do |child|
  66. 10 extract_onclick_actions(child, actions)
  67. end
  68. else
  69. 2 extract_onclick_actions(json_data['child'], actions)
  70. end
  71. end
  72. 1 elsif json_data.is_a?(Array)
  73. 1 json_data.each do |item|
  74. 2 extract_onclick_actions(item, actions)
  75. end
  76. end
  77. 29 actions.to_a
  78. end
  79. 1 def extract_data_properties(json_data, properties = [], depth = 0)
  80. 19 if json_data.is_a?(Hash)
  81. # Stop if this is an include - includes have their own data models
  82. 19 return properties if json_data['include']
  83. # Check for data section at any level, but only process the first one found
  84. 18 if json_data['data'] && properties.empty?
  85. 5 if json_data['data'].is_a?(Array)
  86. 4 json_data['data'].each do |data_item|
  87. 5 if data_item.is_a?(Hash) && data_item['name']
  88. # Check if property already exists (by name) to avoid duplicates
  89. 6 unless properties.any? { |p| p['name'] == data_item['name'] }
  90. # Normalize type using TypeConverter with mode
  91. 5 normalized = Core::TypeConverter.normalize_data_property(data_item, @mode)
  92. 5 properties << normalized
  93. end
  94. end
  95. end
  96. 1 elsif json_data['data'].is_a?(Hash)
  97. # Handle simple data object format from styles
  98. 1 json_data['data'].each do |name, value|
  99. 10 unless properties.any? { |p| p['name'] == name }
  100. # Infer type from value
  101. 4 class_type = if value.is_a?(Integer)
  102. 1 'Int'
  103. 3 elsif value.is_a?(Float)
  104. 1 'Float'
  105. 2 elsif value.is_a?(TrueClass) || value.is_a?(FalseClass)
  106. 1 'Boolean'
  107. else
  108. 1 'String'
  109. end
  110. 4 properties << {
  111. 'name' => name,
  112. 'class' => class_type,
  113. 'defaultValue' => value
  114. }
  115. end
  116. end
  117. end
  118. end
  119. # If we haven't found data yet, continue searching in children
  120. 18 if properties.empty? && json_data['child']
  121. 7 if json_data['child'].is_a?(Array)
  122. 6 json_data['child'].each do |child|
  123. 7 extract_data_properties(child, properties, depth + 1)
  124. # Stop after finding the first data section
  125. 7 break unless properties.empty?
  126. end
  127. else
  128. 1 extract_data_properties(json_data['child'], properties, depth + 1)
  129. end
  130. end
  131. elsif json_data.is_a?(Array)
  132. json_data.each do |item|
  133. extract_data_properties(item, properties, depth)
  134. # Stop after finding the first data section
  135. break unless properties.empty?
  136. end
  137. end
  138. 18 properties
  139. end
  140. 1 def update_data_file(base_name, data_properties, onclick_actions = [])
  141. # Convert base_name to PascalCase for searching
  142. 11 pascal_view_name = to_pascal_case(base_name)
  143. # Check for existing file with different casing
  144. 11 existing_file = find_existing_data_file(pascal_view_name)
  145. 11 if existing_file
  146. # Extract the actual data class name from the existing file
  147. existing_class_name = extract_class_name(existing_file)
  148. if existing_class_name
  149. # Use the exact class name from the existing file
  150. view_name = existing_class_name.sub(/Data$/, '')
  151. else
  152. # Fallback to pascal case if we can't extract the name
  153. view_name = pascal_view_name
  154. end
  155. data_file_path = existing_file
  156. else
  157. # For new files, use pascal case
  158. 11 view_name = pascal_view_name
  159. 11 data_file_path = File.join(@data_dir, "#{view_name}Data.kt")
  160. # If file doesn't exist, create it with empty data structure
  161. 11 unless File.exist?(data_file_path)
  162. # Create directory if needed
  163. 11 FileUtils.mkdir_p(@data_dir)
  164. end
  165. end
  166. # Generate new content
  167. 11 content = generate_data_content(view_name, data_properties, onclick_actions)
  168. # Write the updated content
  169. 11 File.write(data_file_path, content)
  170. 11 puts " Updated Data model: #{data_file_path}"
  171. end
  172. 1 def find_existing_data_file(view_name)
  173. # Try exact match first
  174. 13 exact_path = File.join(@data_dir, "#{view_name}Data.kt")
  175. 13 return exact_path if File.exist?(exact_path)
  176. # Try case-insensitive search
  177. 12 Dir.glob(File.join(@data_dir, '*Data.kt')).find do |file|
  178. File.basename(file, '.kt').downcase == "#{view_name}Data".downcase
  179. end
  180. end
  181. 1 def extract_class_name(file_path)
  182. 2 content = File.read(file_path)
  183. 2 if match = content.match(/data\s+class\s+(\w+Data)\s*\(/)
  184. 1 match[1]
  185. else
  186. nil
  187. end
  188. end
  189. 1 def generate_data_content(view_name, data_properties, onclick_actions = [])
  190. 15 content = <<~KOTLIN
  191. // Generated by kjui_tools - DO NOT EDIT
  192. package #{@package_name}.data
  193. KOTLIN
  194. # Add Color import if any property uses Color type
  195. 27 has_color = data_properties.any? { |prop| prop['class'] == 'Color' }
  196. 15 if has_color
  197. 1 content += "import androidx.compose.ui.graphics.Color\n"
  198. end
  199. # Add Painter import if any property uses Image/Painter type
  200. 27 if data_properties.any? { |prop| prop['class'] == 'Image' || prop['class'] == 'Painter' }
  201. content += "import androidx.compose.ui.graphics.painter.Painter\n"
  202. # Check if any Image default value uses painterResource
  203. needs_painter_resource = data_properties.any? do |prop|
  204. (prop['class'] == 'Image' || prop['class'] == 'Painter') &&
  205. prop['defaultValue'].is_a?(String) &&
  206. prop['defaultValue'].include?('painterResource')
  207. end
  208. if needs_painter_resource
  209. content += "import androidx.compose.ui.res.painterResource\n"
  210. end
  211. end
  212. 15 content += "\ndata class #{view_name}Data(\n"
  213. 15 if data_properties.empty?
  214. 7 content += " // No data properties defined in JSON\n"
  215. 7 content += " val placeholder: String = \"placeholder\"\n"
  216. else
  217. # Add each property with correct type and default value
  218. 8 data_properties.each_with_index do |prop, index|
  219. 12 name = prop['name']
  220. 12 class_type = map_to_kotlin_type(prop['class'])
  221. 12 default_value = prop['defaultValue']
  222. # If no default value or nil, make it nullable
  223. 12 if default_value.nil? || default_value == 'nil'
  224. # Don't add ? if type already ends with ? (already nullable)
  225. 1 if class_type.end_with?('?')
  226. content += " var #{name}: #{class_type} = null"
  227. else
  228. 1 content += " var #{name}: #{class_type}? = null"
  229. end
  230. else
  231. 11 formatted_value = format_default_value(default_value, prop['class'])
  232. 11 content += " var #{name}: #{class_type} = #{formatted_value}"
  233. end
  234. # Add comma if not last property
  235. 12 content += "," if index < data_properties.length - 1
  236. 12 content += "\n"
  237. end
  238. end
  239. 15 content += ") {\n"
  240. # Add companion object with update function
  241. 15 content += " companion object {\n"
  242. 15 content += " // Update properties from map\n"
  243. # Add @Suppress("UNCHECKED_CAST") if there are callback properties
  244. 15 has_callback_properties = data_properties.any? { |prop|
  245. 12 class_type = prop['class'].to_s
  246. 12 class_type.include?('-> Unit') || class_type.include?('-> Void')
  247. }
  248. 15 if has_callback_properties
  249. content += " @Suppress(\"UNCHECKED_CAST\")\n"
  250. end
  251. 15 content += " fun fromMap(map: Map<String, Any>): #{view_name}Data {\n"
  252. 15 content += " return #{view_name}Data(\n"
  253. 15 if !data_properties.empty?
  254. 8 data_properties.each_with_index do |prop, index|
  255. 12 name = prop['name']
  256. 12 class_type = prop['class']
  257. 12 kotlin_type = map_to_kotlin_type(class_type)
  258. # Generate conversion code based on type
  259. 12 content += " #{name} = "
  260. 12 case class_type
  261. when 'String'
  262. 8 content += "map[\"#{name}\"] as? String ?: \"\""
  263. when 'Int'
  264. 1 content += "(map[\"#{name}\"] as? Number)?.toInt() ?: 0"
  265. when 'Double'
  266. content += "(map[\"#{name}\"] as? Number)?.toDouble() ?: 0.0"
  267. when 'Float'
  268. 1 content += "(map[\"#{name}\"] as? Number)?.toFloat() ?: 0f"
  269. when 'Bool', 'Boolean'
  270. 1 content += "map[\"#{name}\"] as? Boolean ?: false"
  271. when 'Color'
  272. 1 content += "map[\"#{name}\"] as? Color ?: Color.Unspecified"
  273. when 'CollectionDataSource'
  274. content += "com.kotlinjsonui.data.CollectionDataSource()"
  275. when /^List<.*>$/
  276. content += "map[\"#{name}\"] as? #{kotlin_type} ?: emptyList()"
  277. when /^Map<.*>$/
  278. content += "map[\"#{name}\"] as? #{kotlin_type} ?: emptyMap()"
  279. else
  280. # For custom types, try to cast directly
  281. content += "map[\"#{name}\"] as? #{kotlin_type}"
  282. end
  283. 12 content += "," if index < data_properties.length - 1
  284. 12 content += "\n"
  285. end
  286. else
  287. 7 content += " placeholder = \"placeholder\"\n"
  288. end
  289. 15 content += " )\n"
  290. 15 content += " }\n"
  291. 15 content += " }\n"
  292. # Add toMap function
  293. 15 content += "\n"
  294. 15 content += " // Convert properties to map for runtime use\n"
  295. 15 content += " fun toMap(): MutableMap<String, Any> {\n"
  296. 15 content += " val map = mutableMapOf<String, Any>()\n"
  297. # Add data properties
  298. 15 if !data_properties.empty?
  299. 8 content += " \n"
  300. 8 content += " // Data properties\n"
  301. 8 data_properties.each do |prop|
  302. 12 name = prop['name']
  303. 12 default_value = prop['defaultValue']
  304. # If it's nullable, check for null
  305. 12 if default_value.nil? || default_value == 'nil'
  306. 1 content += " #{name}?.let { map[\"#{name}\"] = it }\n"
  307. else
  308. 11 content += " map[\"#{name}\"] = #{name}\n"
  309. end
  310. end
  311. end
  312. 15 if data_properties.empty?
  313. 7 content += " // No properties to add\n"
  314. end
  315. 15 content += " \n"
  316. 15 content += " return map\n"
  317. 15 content += " }\n"
  318. 15 content += "}\n"
  319. 15 content
  320. end
  321. 1 def map_to_kotlin_type(json_class)
  322. 38 case json_class
  323. when 'String'
  324. 17 'String'
  325. when 'Int'
  326. 3 'Int'
  327. when 'Double'
  328. 1 'Double'
  329. when 'Float'
  330. 3 'Float'
  331. when 'Bool', 'Boolean'
  332. 4 'Boolean'
  333. when 'CGFloat'
  334. 1 'Float'
  335. when 'Color'
  336. 3 'Color'
  337. when 'Image', 'Painter'
  338. 'Painter'
  339. when 'CollectionDataSource'
  340. # Use the actual CollectionDataSource type
  341. 1 'com.kotlinjsonui.data.CollectionDataSource'
  342. when /^\(\) -> Unit$/
  343. # Non-optional callback becomes optional in data class
  344. 1 '(() -> Unit)?'
  345. when /^\((.+)\) -> Unit$/
  346. # Callback with parameters becomes optional
  347. 1 "((#{$1}) -> Unit)?"
  348. when /^\(\(\) -> Unit\)\?$/
  349. # Already optional, keep as is
  350. 1 '(() -> Unit)?'
  351. when /^\(\((.+)\) -> Unit\)\?$/
  352. # Already optional with params, keep as is
  353. 1 "((#{$1}) -> Unit)?"
  354. else
  355. # Return as-is for custom types
  356. 1 json_class
  357. end
  358. end
  359. 1 def format_default_value(value, json_class)
  360. 29 case json_class
  361. when 'String'
  362. # Handle string default values (matching SwiftUI implementation)
  363. 8 value_str = value.to_s
  364. 8 if value_str == "''"
  365. # Handle '' as empty string (common shorthand)
  366. '""'
  367. 8 elsif value_str.start_with?("'") && value_str.end_with?("'") && value_str.length > 1
  368. # Handle single-quoted strings like "'gone'" -> "gone"
  369. inner_content = value_str[1...-1]
  370. escaped_content = inner_content.gsub('\\', '\\\\').gsub('"', '\\"')
  371. "\"#{escaped_content}\""
  372. 8 elsif !value_str.start_with?('"') || !value_str.end_with?('"')
  373. # Handle unquoted strings like "gone" -> "gone"
  374. 3 escaped_content = value_str.gsub('\\', '\\\\').gsub('"', '\\"')
  375. 3 "\"#{escaped_content}\""
  376. else
  377. # Already properly quoted
  378. 5 value_str
  379. end
  380. when 'Bool', 'Boolean'
  381. # Convert to boolean
  382. 4 if value.is_a?(TrueClass) || value.is_a?(FalseClass)
  383. 3 value.to_s
  384. else
  385. 1 value.to_s.downcase == 'true' ? 'true' : 'false'
  386. end
  387. when 'Int'
  388. # Ensure it's an integer
  389. 3 value.to_i.to_s
  390. when 'Double'
  391. # Ensure it's a double
  392. 1 "#{value.to_f}"
  393. when 'Float', 'CGFloat'
  394. # Ensure it's a float with f suffix
  395. 3 "#{value.to_f}f"
  396. when 'Color'
  397. # Handle color values - type_converter already converts color names to Color(0xFFxxxxxx)
  398. 5 if value.is_a?(String) && value.start_with?('Color(')
  399. value # Already converted Color() expression
  400. 5 elsif value.is_a?(String) && value.start_with?('Color.')
  401. 1 value # Direct Color reference like Color.Red
  402. 4 elsif value.is_a?(String) && value.start_with?('#')
  403. # Hex color - convert to Color()
  404. 2 hex = value.sub('#', '')
  405. 2 if hex.length == 6
  406. 2 "Color(0xFF#{hex.upcase})"
  407. elsif hex.length == 8
  408. "Color(0x#{hex.upcase})"
  409. else
  410. 'Color.Unspecified'
  411. end
  412. else
  413. 2 'Color.Unspecified'
  414. end
  415. when 'CollectionDataSource'
  416. # Return the actual default value string or create new instance
  417. 1 if value.is_a?(String) && value == 'CollectionDataSource()'
  418. 1 'com.kotlinjsonui.data.CollectionDataSource()'
  419. else
  420. 'com.kotlinjsonui.data.CollectionDataSource()'
  421. end
  422. when /^List<.*>$/
  423. # Handle generic List types
  424. 2 if value.is_a?(Array) && value.empty?
  425. 1 'emptyList()'
  426. 1 elsif value == '[]' || value == []
  427. 1 'emptyList()'
  428. else
  429. 'emptyList()'
  430. end
  431. when /^Map<.*>$/
  432. # Handle generic Map types
  433. 2 if value.is_a?(Hash) && value.empty?
  434. 1 'emptyMap()'
  435. 1 elsif value == '{}' || value == {} || value == '{}'
  436. 1 'emptyMap()'
  437. else
  438. 'emptyMap()'
  439. end
  440. else
  441. # For all other cases, use value as-is
  442. value
  443. end
  444. end
  445. 1 def to_pascal_case(str)
  446. # Handle various naming patterns
  447. 14 snake = str.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
  448. .gsub(/([a-z\d])([A-Z])/, '\1_\2')
  449. .downcase
  450. 14 snake.split(/[_\-]/).map(&:capitalize).join
  451. end
  452. end
  453. end
  454. end

lib/compose/generators/cell_generator.rb

96.88% lines covered

96 relevant lines. 93 lines covered and 3 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'json'
  3. 1 require 'fileutils'
  4. 1 require_relative '../../core/config_manager'
  5. 1 require_relative '../../core/project_finder'
  6. 1 module KjuiTools
  7. 1 module Compose
  8. 1 module Generators
  9. 1 class CellGenerator
  10. 1 def initialize(name, options = {})
  11. 16 @name = name
  12. 16 @options = options
  13. 16 @config = Core::ConfigManager.load_config
  14. end
  15. 1 def generate
  16. # Parse name for subdirectories
  17. 14 parts = @name.split('/')
  18. 14 cell_name = parts.last
  19. 14 subdirectory = parts[0...-1].join('/') if parts.length > 1
  20. # Convert subdirectory to snake_case for JSON layouts
  21. 18 snake_subdirectory = parts[0...-1].map { |p| to_snake_case(p) }.join('/') if parts.length > 1
  22. # Keep original PascalCase if provided, otherwise convert
  23. # If the name is already in PascalCase (e.g., ProductCell), keep it
  24. 14 cell_class_name = cell_name
  25. 14 json_file_name = to_snake_case(cell_name)
  26. # Get directories from config
  27. 14 source_dir = @config['source_directory'] || 'src/main'
  28. 14 layouts_dir = @config['layouts_directory'] || 'assets/Layouts'
  29. 14 view_dir = @config['view_directory'] || 'kotlin/com/example/kotlinjsonui/sample/views'
  30. 14 viewmodel_dir = @config['viewmodel_directory'] || 'kotlin/com/example/kotlinjsonui/sample/viewmodels'
  31. 14 data_dir = @config['data_directory'] || 'kotlin/com/example/kotlinjsonui/sample/data'
  32. 14 package_name = @config['package_name'] || 'com.example.kotlinjsonui.sample'
  33. # Create full paths with subdirectory support
  34. # Each cell gets its own directory (using snake_case for Android)
  35. 14 cell_folder_name = to_snake_case(cell_name)
  36. 14 if subdirectory
  37. # JSON uses snake_case subdirectory
  38. # Views use subdirectory structure, but data and viewmodels are flat
  39. 3 json_path = File.join(source_dir, layouts_dir, snake_subdirectory)
  40. 3 swift_path = File.join(source_dir, view_dir, subdirectory, cell_folder_name)
  41. 3 viewmodel_path = File.join(source_dir, viewmodel_dir)
  42. 3 data_path = File.join(source_dir, data_dir)
  43. else
  44. 11 json_path = File.join(source_dir, layouts_dir)
  45. 11 swift_path = File.join(source_dir, view_dir, cell_folder_name)
  46. 11 viewmodel_path = File.join(source_dir, viewmodel_dir)
  47. 11 data_path = File.join(source_dir, data_dir)
  48. end
  49. # Create directories if they don't exist
  50. 14 FileUtils.mkdir_p(json_path)
  51. 14 FileUtils.mkdir_p(swift_path)
  52. 14 FileUtils.mkdir_p(viewmodel_path)
  53. 14 FileUtils.mkdir_p(data_path)
  54. # Create JSON file
  55. 14 json_file = File.join(json_path, "#{json_file_name}.json")
  56. 14 create_json_template(json_file, cell_class_name)
  57. # Create Main Cell View file (add View suffix to class name)
  58. 14 main_kotlin_file = File.join(swift_path, "#{cell_class_name}View.kt")
  59. 14 create_main_cell_template(main_kotlin_file, cell_class_name, json_file_name, subdirectory, package_name)
  60. # Create Generated View file
  61. 14 generated_kotlin_file = File.join(swift_path, "#{cell_class_name}GeneratedView.kt")
  62. 14 create_generated_cell_template(generated_kotlin_file, cell_class_name, json_file_name, subdirectory, package_name)
  63. # Create Data file with item property
  64. 14 data_file = File.join(data_path, "#{cell_class_name}Data.kt")
  65. 14 create_cell_data_template(data_file, cell_class_name, package_name)
  66. # Create ViewModel file
  67. 14 viewmodel_file = File.join(viewmodel_path, "#{cell_class_name}ViewModel.kt")
  68. 14 create_cell_viewmodel_template(viewmodel_file, cell_class_name, json_file_name, subdirectory, package_name)
  69. 14 puts "Generated Collection Cell view:"
  70. 14 puts " JSON: #{json_file}"
  71. 14 puts " Main View: #{main_kotlin_file}"
  72. 14 puts " Generated View: #{generated_kotlin_file}"
  73. 14 puts " Data: #{data_file}"
  74. 14 puts " ViewModel: #{viewmodel_file}"
  75. 14 puts ""
  76. 14 puts "Next steps:"
  77. 14 puts " 1. Edit the JSON layout in #{json_file}"
  78. 14 puts " 2. Run 'kjui build' to generate the Compose code"
  79. 14 puts " 3. Use this cell in Collection components with cellClasses: [\"#{cell_class_name}\"]"
  80. end
  81. 1 private
  82. 1 def create_json_template(file_path, class_name)
  83. 14 return if File.exist?(file_path)
  84. json_content = {
  85. 13 "type" => "View",
  86. "orientation" => "horizontal",
  87. "padding" => 12,
  88. "background" => "#F9F9F9",
  89. "cornerRadius" => 6,
  90. "child" => [
  91. {
  92. "type" => "Text",
  93. "text" => "@{item.title}",
  94. "fontSize" => 14,
  95. "weight" => 1
  96. },
  97. {
  98. "type" => "Text",
  99. "text" => "@{item.value}",
  100. "fontSize" => 14,
  101. "fontWeight" => "bold"
  102. }
  103. ]
  104. }
  105. 13 File.write(file_path, JSON.pretty_generate(json_content))
  106. end
  107. 1 def create_main_cell_template(file_path, class_name, json_name, subdirectory, package_name)
  108. 14 return if File.exist?(file_path)
  109. # Calculate relative package path (must use snake_case for subdirectory in package names)
  110. 18 snake_subdir = subdirectory&.split('/')&.map { |p| to_snake_case(p) }&.join('.')
  111. 14 view_package = if snake_subdir
  112. 3 "#{package_name}.views.#{snake_subdir}.#{to_snake_case(class_name)}"
  113. else
  114. 11 "#{package_name}.views.#{to_snake_case(class_name)}"
  115. end
  116. 14 content = <<~KOTLIN
  117. package #{view_package}
  118. import androidx.compose.runtime.Composable
  119. import androidx.compose.runtime.collectAsState
  120. import androidx.compose.runtime.getValue
  121. import androidx.compose.ui.Modifier
  122. import #{package_name}.viewmodels.#{class_name}ViewModel
  123. @Composable
  124. fun #{class_name}View(
  125. viewModel: #{class_name}ViewModel,
  126. modifier: Modifier = Modifier
  127. ) {
  128. // This is a cell view for use in Collection components
  129. // Data is observed from viewModel using collectAsState
  130. val data by viewModel.data.collectAsState()
  131. #{class_name}GeneratedView(
  132. data = data,
  133. viewModel = viewModel,
  134. modifier = modifier
  135. )
  136. }
  137. KOTLIN
  138. 14 File.write(file_path, content)
  139. end
  140. 1 def create_generated_cell_template(file_path, class_name, json_name, subdirectory, package_name)
  141. 14 return if File.exist?(file_path)
  142. # Calculate relative package path (must use snake_case for subdirectory in package names)
  143. 18 snake_subdir = subdirectory&.split('/')&.map { |p| to_snake_case(p) }&.join('.')
  144. 14 view_package = if snake_subdir
  145. 3 "#{package_name}.views.#{snake_subdir}.#{to_snake_case(class_name)}"
  146. else
  147. 11 "#{package_name}.views.#{to_snake_case(class_name)}"
  148. end
  149. 14 content = <<~KOTLIN
  150. package #{view_package}
  151. import androidx.compose.foundation.background
  152. import androidx.compose.foundation.layout.*
  153. import androidx.compose.material3.*
  154. import androidx.compose.runtime.Composable
  155. import androidx.compose.ui.Alignment
  156. import androidx.compose.ui.Modifier
  157. import androidx.compose.ui.graphics.Color
  158. import androidx.compose.ui.text.font.FontWeight
  159. import androidx.compose.ui.text.style.TextAlign
  160. import androidx.compose.ui.unit.dp
  161. import androidx.compose.ui.unit.sp
  162. import #{package_name}.data.#{class_name}Data
  163. import #{package_name}.viewmodels.#{class_name}ViewModel
  164. import androidx.compose.material3.CircularProgressIndicator
  165. import androidx.compose.foundation.layout.Box
  166. import com.kotlinjsonui.core.DynamicModeManager
  167. import com.kotlinjsonui.components.SafeDynamicView
  168. @Composable
  169. fun #{class_name}GeneratedView(
  170. data: #{class_name}Data,
  171. viewModel: #{class_name}ViewModel,
  172. modifier: Modifier = Modifier
  173. ) {
  174. // Generated Compose code from #{json_name}.json
  175. // This will be updated when you run 'kjui build'
  176. // >>> GENERATED_CODE_START
  177. // Check if Dynamic Mode is active
  178. if (DynamicModeManager.isActive()) {
  179. // Dynamic Mode - use SafeDynamicView for real-time updates
  180. SafeDynamicView(
  181. layoutName = "#{json_name}",
  182. data = data.toMap(),
  183. modifier = modifier,
  184. fallback = {
  185. // Show error or loading state when dynamic view is not available
  186. Box(
  187. modifier = Modifier.fillMaxSize(),
  188. contentAlignment = Alignment.Center
  189. ) {
  190. Text(
  191. text = "Dynamic view not available",
  192. color = Color.Gray
  193. )
  194. }
  195. },
  196. onError = { error ->
  197. // Log error or show error UI
  198. android.util.Log.e("DynamicView", "Error loading #{json_name}: \\$error")
  199. },
  200. onLoading = {
  201. // Show loading indicator
  202. Box(
  203. modifier = Modifier.fillMaxSize(),
  204. contentAlignment = Alignment.Center
  205. ) {
  206. CircularProgressIndicator()
  207. }
  208. }
  209. ) { jsonContent ->
  210. // Parse and render the dynamic JSON content
  211. // This will be handled by the DynamicView implementation
  212. }
  213. } else {
  214. // Static Mode - use generated code
  215. // TODO: Generated content will appear here when you run 'kjui build'
  216. Box(
  217. modifier = modifier
  218. .fillMaxWidth()
  219. .padding(16.dp)
  220. ) {
  221. Text("Cell content will be generated from #{json_name}.json")
  222. }
  223. }
  224. // >>> GENERATED_CODE_END
  225. }
  226. KOTLIN
  227. 14 File.write(file_path, content)
  228. end
  229. 1 def create_cell_data_template(file_path, class_name, package_name)
  230. 14 return if File.exist?(file_path)
  231. 14 content = <<~KOTLIN
  232. package #{package_name}.data
  233. data class #{class_name}Data(
  234. var item: Map<String, Any> = emptyMap()
  235. ) {
  236. companion object {
  237. // Update properties from map
  238. fun fromMap(map: Map<String, Any>): #{class_name}Data {
  239. return #{class_name}Data(
  240. item = map["item"] as? Map<String, Any> ?: emptyMap()
  241. )
  242. }
  243. }
  244. // Convert properties to map for runtime use
  245. fun toMap(): MutableMap<String, Any> {
  246. val map = mutableMapOf<String, Any>()
  247. // Data properties
  248. map["item"] = item
  249. return map
  250. }
  251. }
  252. KOTLIN
  253. 14 File.write(file_path, content)
  254. end
  255. 1 def create_cell_viewmodel_template(file_path, class_name, json_name, subdirectory, package_name)
  256. 14 return if File.exist?(file_path)
  257. 14 content = <<~KOTLIN
  258. package #{package_name}.viewmodels
  259. import android.app.Application
  260. import androidx.lifecycle.AndroidViewModel
  261. import androidx.lifecycle.viewModelScope
  262. import androidx.compose.runtime.mutableStateOf
  263. import androidx.compose.runtime.getValue
  264. import androidx.compose.runtime.setValue
  265. import kotlinx.coroutines.flow.MutableStateFlow
  266. import kotlinx.coroutines.flow.StateFlow
  267. import kotlinx.coroutines.flow.asStateFlow
  268. import kotlinx.coroutines.flow.update
  269. import kotlinx.coroutines.launch
  270. import #{package_name}.data.#{class_name}Data
  271. class #{class_name}ViewModel(application: Application) : AndroidViewModel(application) {
  272. // JSON file reference for hot reload
  273. val jsonFileName = "#{json_name}"
  274. // Data model
  275. private val _data = MutableStateFlow(#{class_name}Data())
  276. val data: StateFlow<#{class_name}Data> = _data.asStateFlow()
  277. // >>> GENERATED_CODE_START
  278. // Auto-generated updateData function - updated by 'kjui build'
  279. fun updateData(updates: Map<String, Any>) {
  280. _data.update { current ->
  281. var updated = current
  282. updates.forEach { (key, value) ->
  283. updated = when (key) {
  284. else -> updated
  285. }
  286. }
  287. updated
  288. }
  289. }
  290. // >>> GENERATED_CODE_END
  291. }
  292. KOTLIN
  293. 14 File.write(file_path, content)
  294. end
  295. 1 def to_pascal_case(str)
  296. str.split(/[_\-]/).map(&:capitalize).join
  297. end
  298. 1 def to_snake_case(str)
  299. 68 str.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
  300. .gsub(/([a-z\d])([A-Z])/, '\1_\2')
  301. .downcase
  302. end
  303. 1 def to_camel_case(str)
  304. pascal = to_pascal_case(str)
  305. pascal[0].downcase + pascal[1..-1]
  306. end
  307. end
  308. end
  309. end
  310. end

lib/compose/generators/converter_generator.rb

82.43% lines covered

148 relevant lines. 122 lines covered and 26 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'fileutils'
  3. 1 require 'json'
  4. 1 require_relative '../../core/logger'
  5. 1 require_relative 'kotlin_component_generator'
  6. 1 require_relative 'dynamic_component_generator'
  7. 1 module KjuiTools
  8. 1 module Compose
  9. 1 module Generators
  10. 1 class ConverterGenerator
  11. 1 def initialize(name, options = {})
  12. 32 @name = name
  13. # Keep original PascalCase name for component
  14. 32 @component_pascal_case = name # e.g., MyTestCard
  15. 32 @component_snake_case = to_snake_case(name) # e.g., my_test_card
  16. 32 @class_name = name + "Component" # e.g., MyTestCardComponent
  17. 32 @options = options
  18. 32 @logger = Core::Logger
  19. end
  20. 1 def generate
  21. 5 @logger.info "Generating custom converter: #{@class_name}"
  22. # Create converter file for static generation
  23. 5 create_converter_file
  24. # Update component mappings file
  25. 5 update_mappings_file
  26. # Create Kotlin component file using separate generator
  27. 5 kotlin_generator = KotlinComponentGenerator.new(@name, @options)
  28. 5 kotlin_generator.generate
  29. # Generate dynamic component file
  30. 5 dynamic_generator = DynamicComponentGenerator.new(@name, @options)
  31. 5 dynamic_generator.generate
  32. # Create or update DynamicComponentInitializer files
  33. 5 create_dynamic_initializers
  34. # Generate attribute definition file
  35. 5 generate_attribute_definition_file
  36. 5 @logger.success "Successfully generated converter: #{@class_name}"
  37. 5 @logger.info "Converter file created at: kjui_tools/lib/compose/components/extensions/#{@component_snake_case}_component.rb"
  38. 5 @logger.info "Mappings file updated with '#{@component_pascal_case}' => '#{@class_name}'"
  39. end
  40. 1 private
  41. 1 def create_converter_file
  42. # Get the path relative to this generator file
  43. 5 generator_dir = File.dirname(__FILE__)
  44. # Go up to lib/compose/components/extensions
  45. 5 extensions_dir = File.join(generator_dir, '..', 'components', 'extensions')
  46. 5 extensions_dir = File.expand_path(extensions_dir)
  47. 5 FileUtils.mkdir_p(extensions_dir)
  48. 5 file_path = File.join(extensions_dir, "#{@component_snake_case}_component.rb")
  49. 5 if File.exist?(file_path)
  50. @logger.warn "Converter file already exists: #{file_path}"
  51. print "Overwrite? (y/n): "
  52. response = gets.chomp.downcase
  53. return unless response == 'y'
  54. end
  55. 5 File.write(file_path, converter_template)
  56. 5 @logger.info "Created converter file: #{file_path}"
  57. end
  58. 1 def update_mappings_file
  59. # Get the path relative to this generator file
  60. 5 generator_dir = File.dirname(__FILE__)
  61. 5 mappings_file = File.join(generator_dir, '..', 'components', 'extensions', 'component_mappings.rb')
  62. 5 mappings_file = File.expand_path(mappings_file)
  63. # Create new mappings file if it doesn't exist
  64. 5 if !File.exist?(mappings_file)
  65. 5 create_initial_mappings_file
  66. 5 return
  67. end
  68. # Read existing mappings
  69. content = File.read(mappings_file)
  70. # Check if mapping already exists
  71. if content.include?("'#{@component_pascal_case}' =>")
  72. @logger.warn "Mapping for '#{@component_pascal_case}' already exists in component_mappings.rb"
  73. return
  74. end
  75. # Add require statement if not present
  76. require_line = "require_relative '#{@component_snake_case}_component'"
  77. unless content.include?(require_line)
  78. # Add require after other requires or at the beginning of the module
  79. if content =~ /^require_relative/
  80. # Add after the last require
  81. content.sub!(/^((?:require_relative.*\n)+)/) do
  82. "#{$1}#{require_line}\n"
  83. end
  84. else
  85. # Add before the module declaration
  86. content.sub!(/^(# Auto-generated.*\n)\n/) do
  87. "#{$1}\n#{require_line}\n\n"
  88. end
  89. end
  90. end
  91. # Add new mapping
  92. new_mapping = " '#{@component_pascal_case}' => #{@class_name},"
  93. # Insert the new mapping before the closing brace of COMPONENT_MAPPINGS
  94. content.sub!(/(COMPONENT_MAPPINGS = \{.*?)(,?)(\s*)( \}\.freeze)/m) do
  95. existing_mappings = $1
  96. last_comma = $2
  97. whitespace = $3
  98. closing = $4
  99. # If there are existing mappings, add the new one with proper formatting
  100. if existing_mappings =~ /=>/
  101. # Ensure the last existing mapping has a comma, then add the new mapping
  102. "#{existing_mappings},\n#{new_mapping}\n#{closing}"
  103. else
  104. # First mapping
  105. "#{existing_mappings}\n#{new_mapping}\n#{closing}"
  106. end
  107. end
  108. File.write(mappings_file, content)
  109. @logger.info "Updated component_mappings.rb with new mapping"
  110. end
  111. 1 def create_initial_mappings_file
  112. # Get the path relative to this generator file
  113. 6 generator_dir = File.dirname(__FILE__)
  114. 6 extensions_dir = File.join(generator_dir, '..', 'components', 'extensions')
  115. 6 extensions_dir = File.expand_path(extensions_dir)
  116. 6 FileUtils.mkdir_p(extensions_dir)
  117. 6 mappings_file = File.join(extensions_dir, 'component_mappings.rb')
  118. 6 content = <<~RUBY
  119. # frozen_string_literal: true
  120. # This file maps custom component types to their converter classes
  121. # Auto-generated by kjui g converter command
  122. require_relative '#{@component_snake_case}_component'
  123. module KjuiTools
  124. module Compose
  125. module Components
  126. module Extensions
  127. COMPONENT_MAPPINGS = {
  128. '#{@component_pascal_case}' => #{@class_name},
  129. }.freeze
  130. end
  131. end
  132. end
  133. end
  134. RUBY
  135. 6 File.write(mappings_file, content)
  136. 6 @logger.info "Created component_mappings.rb with initial mapping"
  137. end
  138. 1 def converter_template
  139. 9 <<~RUBY
  140. # frozen_string_literal: true
  141. require_relative '../../helpers/modifier_builder'
  142. module KjuiTools
  143. module Compose
  144. module Components
  145. module Extensions
  146. class #{@class_name}
  147. def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  148. required_imports&.add(:box)
  149. # Check if this is a container component
  150. children = json_data['children'] || json_data['child']
  151. is_container = children && children.is_a?(Array) && !children.empty?
  152. # Collect parameters
  153. params = []
  154. # Helper method to format values
  155. format_value = lambda do |value, type|
  156. case type.downcase
  157. when 'string', 'text'
  158. # Use ResourceResolver to process strings (checks for resources)
  159. Helpers::ResourceResolver.process_text(value, required_imports)
  160. when 'int', 'integer', 'float', 'double', 'bool', 'boolean'
  161. value.to_s
  162. when 'color'
  163. # Use ResourceResolver to process colors
  164. Helpers::ResourceResolver.process_color(value, required_imports)
  165. else
  166. value.to_s
  167. end
  168. end
  169. #{generate_parameter_collection}
  170. # Build modifiers
  171. modifiers = []
  172. modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  173. modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  174. modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  175. modifiers.concat(Helpers::ModifierBuilder.build_background(json_data, required_imports))
  176. if is_container
  177. # Container component with children
  178. code = indent("#{@component_pascal_case}(", depth)
  179. if !params.empty?
  180. params.each_with_index do |param, index|
  181. separator = index == params.length - 1 ? '' : ','
  182. code += "\\n" + indent("\#{param}\#{separator}", depth + 1)
  183. end
  184. end
  185. if !modifiers.empty?
  186. modifier_str = Helpers::ModifierBuilder.format(modifiers, depth)
  187. code += (params.empty? ? modifier_str : "," + modifier_str)
  188. end
  189. code += "\\n" + indent(") {", depth)
  190. # Process children - return with metadata for ComposeBuilder to handle
  191. return {
  192. code: code,
  193. children: children,
  194. closing: "\\n" + indent("}", depth)
  195. }
  196. else
  197. # Non-container component
  198. if params.empty? && modifiers.empty?
  199. code = indent("#{@component_pascal_case}()", depth)
  200. else
  201. code = indent("#{@component_pascal_case}(", depth)
  202. if !params.empty?
  203. params.each_with_index do |param, index|
  204. separator = index == params.length - 1 ? '' : ','
  205. code += "\\n" + indent("\#{param}\#{separator}", depth + 1)
  206. end
  207. end
  208. if !modifiers.empty?
  209. modifier_str = Helpers::ModifierBuilder.format(modifiers, depth)
  210. code += (params.empty? ? modifier_str : "," + modifier_str)
  211. end
  212. code += "\\n" + indent(")", depth)
  213. end
  214. end
  215. code
  216. end
  217. private
  218. def self.indent(text, level)
  219. return text if level == 0
  220. spaces = ' ' * level
  221. text.split("\\n").map { |line|
  222. line.empty? ? line : spaces + line
  223. }.join("\\n")
  224. end
  225. end
  226. end
  227. end
  228. end
  229. end
  230. RUBY
  231. end
  232. 1 def generate_parameter_collection
  233. 13 return "" if !@options[:attributes] || @options[:attributes].empty?
  234. 7 lines = []
  235. 7 @options[:attributes].each do |key, type|
  236. # Check if this is a binding property (starts with @)
  237. 14 is_binding = key.start_with?('@')
  238. 14 actual_key = is_binding ? key[1..-1] : key
  239. 14 lines << " if json_data['#{actual_key}']"
  240. 14 lines << " value = json_data['#{actual_key}']"
  241. 14 lines << " if value.is_a?(String) && value.match?(/@\\{([^}]+)\\}/)"
  242. 14 lines << " # Handle binding"
  243. 14 lines << " prop_name = value[2..-2]"
  244. 14 lines << " params << \"#{actual_key} = data.\#{prop_name}\""
  245. 14 lines << " else"
  246. 14 lines << " # Handle static value"
  247. 14 lines << " formatted_value = format_value.call(value, '#{type}')"
  248. 14 lines << " params << \"#{actual_key} = \#{formatted_value}\" if formatted_value"
  249. 14 lines << " end"
  250. 14 lines << " end"
  251. end
  252. 7 lines.join("\n")
  253. end
  254. 1 def to_snake_case(str)
  255. 36 str.gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2')
  256. .gsub(/([a-z\d])([A-Z])/,'\1_\2')
  257. .downcase
  258. end
  259. 1 def create_dynamic_initializers
  260. 5 config = Core::ConfigManager.load_config
  261. 5 base_path = config['_config_dir'] || Dir.pwd
  262. 5 source_directory = config['source_directory'] || 'src/main'
  263. 5 package_name = config['package_name'] || Core::ProjectFinder.get_package_name || 'com.example.kotlinjsonui.sample'
  264. # Create debug version
  265. 5 debug_dir = File.join(
  266. base_path,
  267. source_directory.gsub('main', 'debug'),
  268. 'kotlin',
  269. package_name.gsub('.', '/')
  270. )
  271. 5 FileUtils.mkdir_p(debug_dir)
  272. 5 debug_file = File.join(debug_dir, 'DynamicComponentInitializer.kt')
  273. # Only create if it doesn't exist yet
  274. 5 if !File.exist?(debug_file)
  275. 5 File.write(debug_file, generate_debug_initializer_content(package_name))
  276. 5 @logger.info "Created DynamicComponentInitializer (debug)"
  277. end
  278. # Create release version
  279. 5 release_dir = File.join(
  280. base_path,
  281. source_directory.gsub('main', 'release'),
  282. 'kotlin',
  283. package_name.gsub('.', '/')
  284. )
  285. 5 FileUtils.mkdir_p(release_dir)
  286. 5 release_file = File.join(release_dir, 'DynamicComponentInitializer.kt')
  287. # Only create if it doesn't exist yet
  288. 5 if !File.exist?(release_file)
  289. 5 File.write(release_file, generate_release_initializer_content(package_name))
  290. 5 @logger.info "Created DynamicComponentInitializer (release)"
  291. end
  292. end
  293. 1 def generate_debug_initializer_content(package_name)
  294. 6 <<~KOTLIN
  295. package #{package_name}
  296. import androidx.compose.runtime.Composable
  297. import com.google.gson.JsonObject
  298. import com.kotlinjsonui.core.Configuration
  299. import #{package_name}.dynamic.DynamicComponentRegistry
  300. /**
  301. * Debug-only initializer for custom components in dynamic mode
  302. * Auto-generated by kjui converter generator
  303. */
  304. object DynamicComponentInitializer {
  305. /**
  306. * Register custom component handler for dynamic mode
  307. * This is only available in debug builds where DynamicComponentRegistry exists
  308. */
  309. fun initialize() {
  310. Configuration.customComponentHandler = { type, json, data ->
  311. DynamicComponentRegistry.createCustomComponent(type, json, data)
  312. }
  313. }
  314. }
  315. KOTLIN
  316. end
  317. 1 def generate_release_initializer_content(package_name)
  318. 6 <<~KOTLIN
  319. package #{package_name}
  320. /**
  321. * Release version of DynamicComponentInitializer (no-op)
  322. * Auto-generated by kjui converter generator
  323. */
  324. object DynamicComponentInitializer {
  325. /**
  326. * No-op in release builds
  327. */
  328. fun initialize() {
  329. // Dynamic component registry is not available in release builds
  330. }
  331. }
  332. KOTLIN
  333. end
  334. # Generate attribute definition file for validation
  335. 1 def generate_attribute_definition_file
  336. # Skip if no attributes defined
  337. 5 return if !@options[:attributes] || @options[:attributes].empty?
  338. # Get the path relative to this generator file
  339. 4 generator_dir = File.dirname(__FILE__)
  340. 4 definitions_dir = File.join(generator_dir, '..', 'components', 'extensions', 'attribute_definitions')
  341. 4 definitions_dir = File.expand_path(definitions_dir)
  342. 4 FileUtils.mkdir_p(definitions_dir)
  343. 4 file_path = File.join(definitions_dir, "#{@component_pascal_case}.json")
  344. # Build attribute definitions
  345. 4 attribute_defs = {}
  346. 4 @options[:attributes].each do |key, type|
  347. # Remove @ prefix if present (for binding properties)
  348. 9 actual_key = key.start_with?('@') ? key[1..-1] : key
  349. 9 attribute_defs[actual_key] = build_attribute_definition(actual_key, type)
  350. end
  351. # Wrap in component name
  352. definition = {
  353. 4 @component_pascal_case => attribute_defs
  354. }
  355. # Write JSON file
  356. 4 File.write(file_path, JSON.pretty_generate(definition))
  357. 4 @logger.info "Created attribute definition file: #{file_path}"
  358. end
  359. # Map type string to JSON schema type (supports binding for all types)
  360. # @param type [String] The type string from options
  361. # @return [Array, String] JSON schema type(s) - array for binding support
  362. 1 def map_type_to_json_type(type)
  363. 19 case type.downcase
  364. when 'string'
  365. 5 ['string', 'binding']
  366. when 'int', 'integer'
  367. 4 ['number', 'binding']
  368. when 'double', 'float'
  369. 2 ['number', 'binding']
  370. when 'bool', 'boolean'
  371. 3 ['boolean', 'binding']
  372. else
  373. # Custom class types must use binding syntax (@{propertyName})
  374. 5 'binding'
  375. end
  376. end
  377. 1 def build_attribute_definition(actual_key, type)
  378. {
  379. 9 "type" => map_type_to_json_type(type),
  380. "description" => "#{actual_key} attribute"
  381. }
  382. end
  383. end
  384. end
  385. end
  386. end

lib/compose/generators/dynamic_component_generator.rb

64.9% lines covered

151 relevant lines. 98 lines covered and 53 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'fileutils'
  3. 1 require_relative '../../core/logger'
  4. 1 require_relative '../../core/config_manager'
  5. 1 require_relative '../../core/project_finder'
  6. 1 module KjuiTools
  7. 1 module Compose
  8. 1 module Generators
  9. 1 class DynamicComponentGenerator
  10. 1 def initialize(name, options = {})
  11. 40 @name = name
  12. 40 @component_name = name # PascalCase name
  13. 40 @class_name = "Dynamic#{name}Component"
  14. 40 @options = options
  15. 40 @logger = Core::Logger
  16. end
  17. 1 def generate
  18. create_dynamic_component_file
  19. update_dynamic_registry
  20. end
  21. 1 private
  22. 1 def create_dynamic_component_file
  23. config = Core::ConfigManager.load_config
  24. # Use config directory if available (where kjui.config.json was found)
  25. base_path = config['_config_dir'] || Dir.pwd
  26. source_directory = config['source_directory'] || 'src/main'
  27. package_name = config['package_name'] || Core::ProjectFinder.get_package_name || 'com.example.kotlinjsonui.sample'
  28. # Create dynamic components directory in debug source set
  29. dynamic_dir = File.join(
  30. base_path,
  31. source_directory.gsub('main', 'debug'), # Replace main with debug
  32. 'kotlin',
  33. package_name.gsub('.', '/'),
  34. 'dynamic/components/extensions'
  35. )
  36. FileUtils.mkdir_p(dynamic_dir)
  37. file_path = File.join(dynamic_dir, "#{@class_name}.kt")
  38. if File.exist?(file_path)
  39. @logger.warn "Dynamic component file already exists: #{file_path}"
  40. print "Overwrite? (y/n): "
  41. response = gets.chomp.downcase
  42. return unless response == 'y'
  43. end
  44. File.write(file_path, dynamic_template)
  45. @logger.info "Created dynamic component file: #{file_path}"
  46. end
  47. 1 def update_dynamic_registry
  48. config = Core::ConfigManager.load_config
  49. # Use config directory if available (where kjui.config.json was found)
  50. base_path = config['_config_dir'] || Dir.pwd
  51. source_directory = config['source_directory'] || 'src/main'
  52. package_name = config['package_name'] || Core::ProjectFinder.get_package_name || 'com.example.kotlinjsonui.sample'
  53. registry_file = File.join(
  54. base_path,
  55. source_directory.gsub('main', 'debug'), # Replace main with debug
  56. 'kotlin',
  57. package_name.gsub('.', '/'),
  58. 'dynamic/DynamicComponentRegistry.kt'
  59. )
  60. if !File.exist?(registry_file)
  61. create_initial_registry
  62. return
  63. end
  64. # Read existing registry
  65. content = File.read(registry_file)
  66. # Check if component already registered
  67. if content.include?("\"#{@component_name}\"")
  68. @logger.warn "Component '#{@component_name}' already registered in DynamicComponentRegistry"
  69. return
  70. end
  71. # Add new registration with proper indentation
  72. new_registration = <<-REGISTRATION.chomp
  73. "#{@component_name}" -> {
  74. #{@class_name}.create(json, data)
  75. true
  76. }
  77. REGISTRATION
  78. # Insert before the else statement in when block
  79. content.sub!(/(when \(type\) \{.*?)(\n else)/m) do
  80. existing = $1
  81. else_clause = $2
  82. "#{existing}\n#{new_registration}#{else_clause}"
  83. end
  84. # Add import if not present
  85. config = Core::ConfigManager.load_config
  86. package_name = config['package_name'] || 'com.example.kotlinjsonui.sample'
  87. import_line = "import #{package_name}.dynamic.components.extensions.#{@class_name}"
  88. unless content.include?(import_line)
  89. # Add import after the last import line
  90. content.sub!(/(import .+\n)(\n)/) do
  91. "#{$1}#{import_line}\n#{$2}"
  92. end
  93. end
  94. File.write(registry_file, content)
  95. @logger.info "Updated DynamicComponentRegistry with new component"
  96. end
  97. 1 def create_initial_registry
  98. config = Core::ConfigManager.load_config
  99. # Use config directory if available (where kjui.config.json was found)
  100. base_path = config['_config_dir'] || Dir.pwd
  101. source_directory = config['source_directory'] || 'src/main'
  102. package_name = config['package_name'] || Core::ProjectFinder.get_package_name || 'com.example.kotlinjsonui.sample'
  103. registry_dir = File.join(
  104. base_path,
  105. source_directory.gsub('main', 'debug'), # Replace main with debug
  106. 'kotlin',
  107. package_name.gsub('.', '/'),
  108. 'dynamic'
  109. )
  110. FileUtils.mkdir_p(registry_dir)
  111. registry_file = File.join(registry_dir, 'DynamicComponentRegistry.kt')
  112. config = Core::ConfigManager.load_config
  113. package_name = config['package_name'] || 'com.example.kotlinjsonui.sample'
  114. content = <<~KOTLIN
  115. package #{package_name}.dynamic
  116. import androidx.compose.runtime.Composable
  117. import com.google.gson.JsonObject
  118. import #{package_name}.dynamic.components.extensions.#{@class_name}
  119. /**
  120. * Registry for dynamic custom components
  121. * Auto-generated by kjui converter generator
  122. */
  123. object DynamicComponentRegistry {
  124. @Composable
  125. fun createCustomComponent(
  126. type: String,
  127. json: JsonObject,
  128. data: Map<String, Any>
  129. ): Boolean {
  130. return when (type) {
  131. "#{@component_name}" -> {
  132. #{@class_name}.create(json, data)
  133. true
  134. }
  135. else -> false
  136. }
  137. }
  138. }
  139. KOTLIN
  140. File.write(registry_file, content)
  141. @logger.info "Created DynamicComponentRegistry with initial component"
  142. end
  143. 1 def dynamic_template
  144. 2 config = Core::ConfigManager.load_config
  145. 2 package_name = config['package_name'] || 'com.example.kotlinjsonui.sample'
  146. # Determine if this is a container component
  147. 2 is_container = @options[:is_container]
  148. 2 <<~KOTLIN
  149. package #{package_name}.dynamic.components.extensions
  150. import androidx.compose.runtime.Composable
  151. import androidx.compose.ui.Modifier
  152. import com.google.gson.JsonObject
  153. import com.google.gson.JsonElement
  154. #{generate_dynamic_imports}
  155. import com.kotlinjsonui.dynamic.helpers.ModifierBuilder
  156. import #{package_name}.extensions.#{@component_name}
  157. /**
  158. * Dynamic wrapper for #{@component_name} component
  159. * Auto-generated by kjui converter generator
  160. */
  161. object #{@class_name} {
  162. @Composable
  163. fun create(
  164. json: JsonObject,
  165. data: Map<String, Any> = emptyMap()
  166. ) {
  167. // Parse attributes
  168. #{generate_dynamic_parameter_parsing}
  169. // Build modifier
  170. val modifier = ModifierBuilder.buildModifier(json)
  171. #{if is_container
  172. 1 "// Call the custom component with children\n" +
  173. " #{@component_name}(\n" +
  174. generate_component_parameters +
  175. " modifier = modifier\n" +
  176. " ) {\n" +
  177. " // Process children\n" +
  178. " val children = json.get(\"children\")?.asJsonArray ?: json.get(\"child\")?.asJsonArray\n" +
  179. " children?.forEach { childJson ->\n" +
  180. " if (childJson.isJsonObject) {\n" +
  181. " com.kotlinjsonui.dynamic.DynamicView(\n" +
  182. " json = childJson.asJsonObject,\n" +
  183. " data = data\n" +
  184. " )\n" +
  185. " }\n" +
  186. " }\n" +
  187. " }"
  188. else
  189. 1 "// Call the custom component\n" +
  190. " #{@component_name}(\n" +
  191. generate_component_parameters +
  192. " modifier = modifier\n" +
  193. " )"
  194. end}
  195. }
  196. #{generate_helper_methods}
  197. }
  198. KOTLIN
  199. end
  200. 1 def generate_dynamic_imports
  201. 6 return "" if !@options[:attributes] || @options[:attributes].empty?
  202. 3 imports = []
  203. 3 @options[:attributes].each do |key, type|
  204. 3 case type.downcase
  205. when 'alignment'
  206. 1 imports << "import androidx.compose.ui.Alignment"
  207. when 'text', 'string'
  208. 1 imports << "import androidx.compose.ui.text.style.TextAlign"
  209. when 'color'
  210. 1 imports << "import androidx.compose.ui.graphics.Color"
  211. end
  212. end
  213. 3 imports.uniq.join("\n")
  214. end
  215. 1 def generate_attribute_docs
  216. 3 return " * - child/children: Array of child components" if !@options[:attributes] || @options[:attributes].empty?
  217. 2 docs = [" * - child/children: Array of child components"]
  218. 2 @options[:attributes].each do |key, type|
  219. 2 is_binding = key.start_with?('@')
  220. 2 actual_key = is_binding ? key[1..-1] : key
  221. 2 binding_note = is_binding ? " (supports @{binding})" : ""
  222. 2 docs << " * - #{actual_key}: #{type}#{binding_note}"
  223. end
  224. 2 docs.join("\n")
  225. end
  226. 1 def generate_dynamic_parameter_parsing
  227. 4 return "" if !@options[:attributes] || @options[:attributes].empty?
  228. 1 lines = []
  229. 1 @options[:attributes].each do |key, type|
  230. 1 is_binding = key.start_with?('@')
  231. 1 actual_key = is_binding ? key[1..-1] : key
  232. 1 method_name = get_parser_method_name(type)
  233. 1 lines << " val #{actual_key} = #{method_name}(json.get(\"#{actual_key}\"), data)"
  234. end
  235. 1 lines.join("\n")
  236. end
  237. 1 def get_parser_method_name(type)
  238. 10 case type.downcase
  239. when 'string', 'text'
  240. 3 'parseString'
  241. when 'int', 'integer'
  242. 2 'parseInt'
  243. when 'bool', 'boolean'
  244. 2 'parseBoolean'
  245. when 'color'
  246. 1 'parseColor'
  247. when 'float', 'double'
  248. 1 'parseFloat'
  249. else
  250. 1 'parseString'
  251. end
  252. end
  253. 1 def generate_component_parameters
  254. 4 return "" if !@options[:attributes] || @options[:attributes].empty?
  255. 1 lines = []
  256. 1 @options[:attributes].each do |key, type|
  257. 1 is_binding = key.start_with?('@')
  258. 1 actual_key = is_binding ? key[1..-1] : key
  259. # Generate parameter with null safety
  260. 1 lines << " #{actual_key} = #{actual_key} ?: #{get_default_value(type)},"
  261. end
  262. 1 lines.join("\n") + "\n"
  263. end
  264. 1 def get_default_value(type)
  265. 7 case type.downcase
  266. when 'string', 'text'
  267. 2 '""'
  268. when 'int', 'integer'
  269. 1 '0'
  270. when 'bool', 'boolean'
  271. 1 'false'
  272. when 'float', 'double'
  273. 1 '0.0'
  274. when 'color'
  275. 1 'androidx.compose.ui.graphics.Color.Unspecified'
  276. else
  277. 1 'null'
  278. end
  279. end
  280. 1 def generate_helper_methods
  281. 7 return "" if !@options[:attributes] || @options[:attributes].empty?
  282. 4 methods = []
  283. 4 types_added = []
  284. 4 @options[:attributes].each do |key, type|
  285. 4 next if types_added.include?(type.downcase)
  286. 4 types_added << type.downcase
  287. 4 case type.downcase
  288. when 'string', 'text'
  289. 1 methods << string_parser_method
  290. when 'int', 'integer'
  291. 1 methods << int_parser_method
  292. when 'bool', 'boolean'
  293. 1 methods << bool_parser_method
  294. when 'color'
  295. 1 methods << color_parser_method
  296. end
  297. end
  298. 4 methods.join("\n\n")
  299. end
  300. 1 def string_parser_method
  301. 1 <<~KOTLIN
  302. private fun parseString(element: com.google.gson.JsonElement?, data: Map<String, Any>): String? {
  303. if (element == null || element.isJsonNull) return null
  304. val value = element.asString
  305. // Check for binding
  306. if (value.startsWith("@{") && value.endsWith("}")) {
  307. val propertyName = value.substring(2, value.length - 1)
  308. return data[propertyName]?.toString()
  309. }
  310. return value
  311. }
  312. KOTLIN
  313. end
  314. 1 def int_parser_method
  315. 1 <<~KOTLIN
  316. private fun parseInt(element: com.google.gson.JsonElement?, data: Map<String, Any>): Int? {
  317. if (element == null || element.isJsonNull) return null
  318. if (element.isJsonPrimitive) {
  319. val primitive = element.asJsonPrimitive
  320. if (primitive.isNumber) {
  321. return primitive.asInt
  322. } else if (primitive.isString) {
  323. val value = primitive.asString
  324. // Check for binding
  325. if (value.startsWith("@{") && value.endsWith("}")) {
  326. val propertyName = value.substring(2, value.length - 1)
  327. return (data[propertyName] as? Number)?.toInt()
  328. }
  329. return value.toIntOrNull()
  330. }
  331. }
  332. return null
  333. }
  334. KOTLIN
  335. end
  336. 1 def bool_parser_method
  337. 1 <<~KOTLIN
  338. private fun parseBoolean(element: com.google.gson.JsonElement?, data: Map<String, Any>): Boolean? {
  339. if (element == null || element.isJsonNull) return null
  340. if (element.isJsonPrimitive) {
  341. val primitive = element.asJsonPrimitive
  342. if (primitive.isBoolean) {
  343. return primitive.asBoolean
  344. } else if (primitive.isString) {
  345. val value = primitive.asString
  346. // Check for binding
  347. if (value.startsWith("@{") && value.endsWith("}")) {
  348. val propertyName = value.substring(2, value.length - 1)
  349. return data[propertyName] as? Boolean
  350. }
  351. return value.toBooleanStrictOrNull()
  352. }
  353. }
  354. return null
  355. }
  356. KOTLIN
  357. end
  358. 1 def color_parser_method
  359. 1 <<~KOTLIN
  360. private fun parseColor(element: com.google.gson.JsonElement?, data: Map<String, Any>): Color? {
  361. if (element == null || element.isJsonNull) return null
  362. if (element.isJsonPrimitive && element.asJsonPrimitive.isString) {
  363. val value = element.asString
  364. // Check for binding
  365. if (value.startsWith("@{") && value.endsWith("}")) {
  366. val propertyName = value.substring(2, value.length - 1)
  367. val boundValue = data[propertyName]?.toString()
  368. return boundValue?.let { parseColorString(it) }
  369. }
  370. return parseColorString(value)
  371. }
  372. return null
  373. }
  374. private fun parseColorString(value: String): Color? {
  375. return if (value.startsWith("#")) {
  376. try {
  377. Color(android.graphics.Color.parseColor(value))
  378. } catch (e: Exception) {
  379. null
  380. }
  381. } else {
  382. null
  383. }
  384. }
  385. KOTLIN
  386. end
  387. end
  388. end
  389. end
  390. end

lib/compose/generators/kotlin_component_generator.rb

83.64% lines covered

110 relevant lines. 92 lines covered and 18 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'fileutils'
  3. 1 require_relative '../../core/logger'
  4. 1 require_relative '../../core/config_manager'
  5. 1 require_relative '../../core/project_finder'
  6. 1 module KjuiTools
  7. 1 module Compose
  8. 1 module Generators
  9. 1 class KotlinComponentGenerator
  10. 1 def initialize(name, options = {})
  11. 43 @name = name
  12. 43 @component_name = name # PascalCase name
  13. 43 @package_name = get_package_name
  14. 43 @options = options
  15. 43 @logger = Core::Logger
  16. end
  17. 1 def generate
  18. create_kotlin_file
  19. end
  20. 1 private
  21. 1 def create_kotlin_file
  22. config = Core::ConfigManager.load_config
  23. # Use config directory if available (where kjui.config.json was found)
  24. base_path = config['_config_dir'] || Dir.pwd
  25. source_directory = config['source_directory'] || 'src/main'
  26. package_name = config['package_name'] || Core::ProjectFinder.get_package_name || 'com.example.kotlinjsonui.sample'
  27. # Get extension directory from config
  28. extension_directory = config['extension_directory'] || "kotlin/#{package_name.gsub('.', '/')}/extensions"
  29. # Build extension directory path
  30. extension_dir = File.join(
  31. base_path,
  32. source_directory,
  33. extension_directory
  34. )
  35. FileUtils.mkdir_p(extension_dir)
  36. kotlin_file_path = File.join(extension_dir, "#{@component_name}.kt")
  37. if File.exist?(kotlin_file_path)
  38. @logger.warn "Kotlin file already exists: #{kotlin_file_path}"
  39. print "Overwrite? (y/n): "
  40. response = gets.chomp.downcase
  41. return unless response == 'y'
  42. end
  43. File.write(kotlin_file_path, kotlin_template)
  44. @logger.info "Created Kotlin file: #{kotlin_file_path}"
  45. end
  46. 1 def get_package_name
  47. 44 config = Core::ConfigManager.load_config
  48. 44 base_package = config['package_name'] || 'com.example.kotlinjsonui.sample'
  49. 44 "#{base_package}.extensions"
  50. end
  51. 1 def kotlin_template
  52. 2 if @options[:is_container] != false
  53. 1 container_template
  54. else
  55. 1 non_container_template
  56. end
  57. end
  58. 1 def container_template
  59. 3 imports = generate_kotlin_imports
  60. 3 params = generate_kotlin_parameters
  61. 3 template = <<~KOTLIN
  62. package #{@package_name}
  63. import androidx.compose.foundation.layout.Box
  64. import androidx.compose.foundation.layout.BoxScope
  65. import androidx.compose.runtime.Composable
  66. import androidx.compose.ui.Modifier
  67. KOTLIN
  68. 3 template += imports + "\n" if !imports.empty?
  69. 3 template += "\n"
  70. 3 template += <<~KOTLIN
  71. /**
  72. * Custom #{@component_name} component
  73. * Generated by kjui converter generator
  74. *
  75. * Regenerate with:
  76. * kjui g converter #{@component_name} --container#{format_attributes_for_command}
  77. */
  78. @Composable
  79. fun #{@component_name}(
  80. KOTLIN
  81. 3 if !params.empty?
  82. template += params
  83. end
  84. 3 template += <<~KOTLIN
  85. modifier: Modifier = Modifier,
  86. content: @Composable BoxScope.() -> Unit
  87. ) {
  88. Box(
  89. modifier = modifier
  90. ) {
  91. // Custom container implementation
  92. content()
  93. }
  94. }
  95. KOTLIN
  96. 3 template
  97. end
  98. 1 def non_container_template
  99. 2 imports = generate_kotlin_imports
  100. 2 params = generate_kotlin_parameters
  101. 2 template = <<~KOTLIN
  102. package #{@package_name}
  103. import androidx.compose.foundation.layout.Box
  104. import androidx.compose.runtime.Composable
  105. import androidx.compose.ui.Modifier
  106. KOTLIN
  107. 2 template += imports + "\n" if !imports.empty?
  108. 2 template += "\n"
  109. 2 template += <<~KOTLIN
  110. /**
  111. * Custom #{@component_name} component
  112. * Generated by kjui converter generator
  113. *
  114. * Regenerate with:
  115. * kjui g converter #{@component_name} --no-container#{format_attributes_for_command}
  116. */
  117. @Composable
  118. fun #{@component_name}(
  119. KOTLIN
  120. 2 if !params.empty?
  121. template += params
  122. end
  123. 2 template += <<~KOTLIN
  124. modifier: Modifier = Modifier
  125. ) {
  126. // TODO: Implement your custom component
  127. Box(modifier = modifier) {
  128. // Component content
  129. }
  130. }
  131. KOTLIN
  132. 2 template
  133. end
  134. 1 def generate_kotlin_imports
  135. 9 return "" if !@options[:attributes] || @options[:attributes].empty?
  136. 3 imports = []
  137. 3 @options[:attributes].each do |key, type|
  138. 3 case type.downcase
  139. when 'color'
  140. 1 imports << "import androidx.compose.ui.graphics.Color"
  141. when 'dp', 'size'
  142. 1 imports << "import androidx.compose.ui.unit.dp"
  143. 1 imports << "import androidx.compose.ui.unit.Dp"
  144. when 'alignment'
  145. 1 imports << "import androidx.compose.ui.Alignment"
  146. when 'text', 'string'
  147. # No special import needed
  148. when 'int', 'float', 'double'
  149. # No special import needed
  150. when 'boolean', 'bool'
  151. # No special import needed
  152. end
  153. end
  154. 3 imports.uniq.join("\n")
  155. end
  156. 1 def generate_kotlin_parameters
  157. 7 return "" if !@options[:attributes] || @options[:attributes].empty?
  158. 1 params = []
  159. 1 @options[:attributes].each do |key, type|
  160. 1 is_binding = key.start_with?('@')
  161. 1 actual_key = is_binding ? key[1..-1] : key
  162. 1 kotlin_type = map_type_to_kotlin(type)
  163. 1 default_value = get_default_value(type)
  164. 1 params << " #{actual_key}: #{kotlin_type}#{default_value},"
  165. end
  166. 1 params.join("\n") + "\n"
  167. end
  168. 1 def map_type_to_kotlin(type)
  169. 14 case type.downcase
  170. when 'string', 'text'
  171. 3 'String'
  172. when 'int', 'integer'
  173. 2 'Int'
  174. when 'float'
  175. 1 'Float'
  176. when 'double'
  177. 1 'Double'
  178. when 'bool', 'boolean'
  179. 2 'Boolean'
  180. when 'color'
  181. 1 'Color'
  182. when 'dp', 'size'
  183. 2 'Dp'
  184. when 'alignment'
  185. 1 'Alignment'
  186. else
  187. 1 'Any'
  188. end
  189. end
  190. 1 def get_default_value(type)
  191. 10 case type.downcase
  192. when 'string', 'text'
  193. 2 ' = ""'
  194. when 'int', 'integer'
  195. 1 ' = 0'
  196. when 'float'
  197. 1 ' = 0f'
  198. when 'double'
  199. 1 ' = 0.0'
  200. when 'bool', 'boolean'
  201. 1 ' = false'
  202. when 'color'
  203. 1 ' = Color.Unspecified'
  204. when 'dp', 'size'
  205. 1 ' = 0.dp'
  206. when 'alignment'
  207. 1 ' = Alignment.TopStart'
  208. else
  209. 1 ' = null'
  210. end
  211. end
  212. 1 def format_attributes_for_command
  213. 7 return "" if !@options[:attributes] || @options[:attributes].empty?
  214. 1 attrs = @options[:attributes].map do |key, type|
  215. 2 " --attr #{key}:#{type}"
  216. end.join("")
  217. 1 attrs
  218. end
  219. end
  220. end
  221. end
  222. end

lib/compose/generators/view_adapter_generator.rb

95.7% lines covered

93 relevant lines. 89 lines covered and 4 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'fileutils'
  3. 1 require_relative '../../core/logger'
  4. 1 require_relative '../../core/config_manager'
  5. 1 require_relative '../../core/project_finder'
  6. 1 module KjuiTools
  7. 1 module Compose
  8. 1 module Generators
  9. # Generates adapter for existing View to be used in Dynamic mode
  10. # This allows views like HomeView to be rendered when TabView specifies view: "home"
  11. 1 class ViewAdapterGenerator
  12. 1 def initialize(name, options = {})
  13. 30 @name = name # PascalCase name like Home
  14. 30 @view_name = "#{name}View" # HomeView
  15. 30 @adapter_class_name = "#{name}ViewAdapter" # HomeViewAdapter
  16. 30 @options = options
  17. 30 @logger = Core::Logger
  18. 30 @command = "kjui g adapter #{name}"
  19. end
  20. 1 def generate
  21. 23 @logger.info "Generating view adapter for: #{@view_name}"
  22. 23 config = Core::ConfigManager.load_config
  23. 23 base_path = config['_config_dir'] || Dir.pwd
  24. 23 source_directory = config['source_directory'] || 'src/main'
  25. 23 package_name = config['package_name'] || Core::ProjectFinder.get_package_name || 'com.example.app'
  26. # Store for use in templates
  27. 23 @package_name = package_name
  28. 23 @base_path = base_path
  29. 23 @source_directory = source_directory
  30. # Create adapter file in debug source set
  31. 23 create_adapter_file
  32. # Update DynamicComponentRegistry
  33. 23 update_registry_file
  34. # Create DynamicComponentInitializer (debug/release versions)
  35. 23 create_dynamic_initializers
  36. 23 @logger.success "Successfully generated adapter: #{@adapter_class_name}"
  37. 23 @logger.info "Don't forget to call DynamicComponentInitializer.initialize() in your app initialization."
  38. 23 true
  39. end
  40. 1 private
  41. 1 def create_adapter_file
  42. # Create dynamic components directory in debug source set
  43. 18 adapter_dir = File.join(
  44. @base_path,
  45. @source_directory.gsub('main', 'debug'),
  46. 'kotlin',
  47. @package_name.gsub('.', '/'),
  48. 'dynamic/components/adapters'
  49. )
  50. 18 FileUtils.mkdir_p(adapter_dir)
  51. 18 adapter_file = File.join(adapter_dir, "#{@adapter_class_name}.kt")
  52. 18 if File.exist?(adapter_file)
  53. @logger.warn "Adapter file already exists: #{adapter_file}"
  54. print "Overwrite? (y/n): "
  55. response = gets.chomp.downcase
  56. return unless response == 'y'
  57. end
  58. 18 File.write(adapter_file, adapter_template)
  59. 18 @logger.info "Created adapter file: #{adapter_file}"
  60. end
  61. 1 def update_registry_file
  62. 18 registry_file = File.join(
  63. @base_path,
  64. @source_directory.gsub('main', 'debug'),
  65. 'kotlin',
  66. @package_name.gsub('.', '/'),
  67. 'dynamic/DynamicComponentRegistry.kt'
  68. )
  69. 18 if !File.exist?(registry_file)
  70. 14 create_initial_registry
  71. 14 return
  72. end
  73. # Read existing registry
  74. 4 content = File.read(registry_file, encoding: 'UTF-8')
  75. # Check if adapter already registered
  76. 4 if content.include?("\"#{@name.downcase}\"") || content.include?("\"#{to_snake_case(@name)}\"")
  77. 1 @logger.warn "View '#{@name}' already registered in DynamicComponentRegistry"
  78. 1 return
  79. end
  80. # Add new registration with proper indentation
  81. # Use snake_case for component type (e.g., "home", "home_screen")
  82. 3 component_type = to_snake_case(@name)
  83. 3 new_registration = <<-REGISTRATION.chomp
  84. "#{component_type}" -> {
  85. #{@adapter_class_name}.create(json, data)
  86. true
  87. }
  88. REGISTRATION
  89. # Insert before the else statement in when block
  90. 3 content.sub!(/(when \(type\) \{.*?)(\n else)/m) do
  91. 3 existing = $1
  92. 3 else_clause = $2
  93. 3 "#{existing}\n#{new_registration}#{else_clause}"
  94. end
  95. # Add import if not present
  96. 3 import_line = "import #{@package_name}.dynamic.components.adapters.#{@adapter_class_name}"
  97. 3 unless content.include?(import_line)
  98. # Add import after the last import line
  99. 3 content.sub!(/(import .+\n)(\n)/) do
  100. 3 "#{$1}#{import_line}\n#{$2}"
  101. end
  102. end
  103. 3 File.write(registry_file, content)
  104. 3 @logger.info "Updated DynamicComponentRegistry with #{@adapter_class_name}"
  105. end
  106. 1 def create_initial_registry
  107. 14 registry_dir = File.join(
  108. @base_path,
  109. @source_directory.gsub('main', 'debug'),
  110. 'kotlin',
  111. @package_name.gsub('.', '/'),
  112. 'dynamic'
  113. )
  114. 14 FileUtils.mkdir_p(registry_dir)
  115. 14 registry_file = File.join(registry_dir, 'DynamicComponentRegistry.kt')
  116. 14 component_type = to_snake_case(@name)
  117. 14 content = <<~KOTLIN
  118. package #{@package_name}.dynamic
  119. import androidx.compose.runtime.Composable
  120. import com.google.gson.JsonObject
  121. import #{@package_name}.dynamic.components.adapters.#{@adapter_class_name}
  122. /**
  123. * Registry for dynamic custom components
  124. * Auto-generated by #{@command}
  125. */
  126. object DynamicComponentRegistry {
  127. @Composable
  128. fun createCustomComponent(
  129. type: String,
  130. json: JsonObject,
  131. data: Map<String, Any>
  132. ): Boolean {
  133. return when (type) {
  134. "#{component_type}" -> {
  135. #{@adapter_class_name}.create(json, data)
  136. true
  137. }
  138. else -> false
  139. }
  140. }
  141. }
  142. KOTLIN
  143. 14 File.write(registry_file, content)
  144. 14 @logger.info "Created DynamicComponentRegistry with #{@adapter_class_name}"
  145. end
  146. 1 def adapter_template
  147. # Convert name to snake_case for component type matching and subdirectory
  148. # e.g., "Home" -> "home", "HomeScreen" -> "home_screen"
  149. 23 component_type = to_snake_case(@name)
  150. 23 <<~KOTLIN
  151. package #{@package_name}.dynamic.components.adapters
  152. import androidx.compose.runtime.Composable
  153. import com.google.gson.JsonObject
  154. import #{@package_name}.views.#{component_type}.#{@view_name}
  155. /**
  156. * Adapter to render #{@view_name} in Dynamic mode
  157. * Use in TabView with: "view": "#{component_type}"
  158. * Generated by: #{@command}
  159. */
  160. object #{@adapter_class_name} {
  161. /**
  162. * Create the view
  163. */
  164. @Composable
  165. fun create(
  166. json: JsonObject,
  167. data: Map<String, Any>
  168. ) {
  169. // Render the actual view
  170. #{@view_name}()
  171. }
  172. }
  173. KOTLIN
  174. end
  175. 1 def to_snake_case(str)
  176. 48 str.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
  177. .gsub(/([a-z\d])([A-Z])/, '\1_\2')
  178. .downcase
  179. end
  180. 1 def create_dynamic_initializers
  181. # Create debug version
  182. 23 debug_dir = File.join(
  183. @base_path,
  184. @source_directory.gsub('main', 'debug'),
  185. 'kotlin',
  186. @package_name.gsub('.', '/')
  187. )
  188. 23 FileUtils.mkdir_p(debug_dir)
  189. 23 debug_file = File.join(debug_dir, 'DynamicComponentInitializer.kt')
  190. # Only create if it doesn't exist yet
  191. 23 unless File.exist?(debug_file)
  192. 20 File.write(debug_file, generate_debug_initializer_content)
  193. 20 @logger.info "Created DynamicComponentInitializer (debug)"
  194. end
  195. # Create release version
  196. 23 release_dir = File.join(
  197. @base_path,
  198. @source_directory.gsub('main', 'release'),
  199. 'kotlin',
  200. @package_name.gsub('.', '/')
  201. )
  202. 23 FileUtils.mkdir_p(release_dir)
  203. 23 release_file = File.join(release_dir, 'DynamicComponentInitializer.kt')
  204. # Only create if it doesn't exist yet
  205. 23 unless File.exist?(release_file)
  206. 21 File.write(release_file, generate_release_initializer_content)
  207. 21 @logger.info "Created DynamicComponentInitializer (release)"
  208. end
  209. end
  210. 1 def generate_debug_initializer_content
  211. 20 <<~KOTLIN
  212. package #{@package_name}
  213. import androidx.compose.runtime.Composable
  214. import com.google.gson.JsonObject
  215. import com.kotlinjsonui.core.Configuration
  216. import #{@package_name}.dynamic.DynamicComponentRegistry
  217. /**
  218. * Debug-only initializer for custom components in dynamic mode
  219. * Auto-generated by #{@command}
  220. */
  221. object DynamicComponentInitializer {
  222. /**
  223. * Register custom component handler for dynamic mode
  224. * This is only available in debug builds where DynamicComponentRegistry exists
  225. */
  226. fun initialize() {
  227. Configuration.customComponentHandler = { type, json, data ->
  228. DynamicComponentRegistry.createCustomComponent(type, json, data)
  229. }
  230. }
  231. }
  232. KOTLIN
  233. end
  234. 1 def generate_release_initializer_content
  235. 21 <<~KOTLIN
  236. package #{@package_name}
  237. /**
  238. * Release version of DynamicComponentInitializer (no-op)
  239. * Auto-generated by #{@command}
  240. */
  241. object DynamicComponentInitializer {
  242. /**
  243. * No-op in release builds
  244. */
  245. fun initialize() {
  246. // Dynamic component registry is not available in release builds
  247. }
  248. }
  249. KOTLIN
  250. end
  251. end
  252. end
  253. end
  254. end

lib/compose/generators/view_generator.rb

75.18% lines covered

137 relevant lines. 103 lines covered and 34 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'json'
  3. 1 require 'fileutils'
  4. 1 require_relative '../../core/config_manager'
  5. 1 require_relative '../../core/project_finder'
  6. 1 module KjuiTools
  7. 1 module Compose
  8. 1 module Generators
  9. 1 class ViewGenerator
  10. 1 def initialize(name, options = {})
  11. 19 @name = name
  12. 19 @options = options
  13. 19 @config = Core::ConfigManager.load_config
  14. end
  15. 1 def generate
  16. # Parse name for subdirectories
  17. 17 parts = @name.split('/')
  18. 17 view_name = parts.last
  19. 17 subdirectory = parts[0...-1].join('/') if parts.length > 1
  20. # Convert to proper case
  21. 17 view_class_name = to_pascal_case(view_name)
  22. 17 json_file_name = to_snake_case(view_name)
  23. # Get directories from config
  24. 17 source_dir = @config['source_directory'] || 'src/main'
  25. 17 layouts_dir = @config['layouts_directory'] || 'assets/Layouts'
  26. 17 view_dir = @config['view_directory'] || 'kotlin/com/example/kotlinjsonui/sample/views'
  27. 17 viewmodel_dir = @config['viewmodel_directory'] || 'kotlin/com/example/kotlinjsonui/sample/viewmodels'
  28. 17 data_dir = @config['data_directory'] || 'kotlin/com/example/kotlinjsonui/sample/data'
  29. 17 package_name = @config['package_name'] || 'com.example.kotlinjsonui.sample'
  30. # Create full paths with subdirectory support
  31. # Each view gets its own directory (using snake_case for Android)
  32. 17 view_folder_name = to_snake_case(view_name)
  33. 17 if subdirectory
  34. # Views use subdirectory structure, but data and viewmodels are flat
  35. 1 json_path = File.join(source_dir, layouts_dir, subdirectory)
  36. 1 swift_path = File.join(source_dir, view_dir, subdirectory, view_folder_name)
  37. 1 viewmodel_path = File.join(source_dir, viewmodel_dir)
  38. 1 data_path = File.join(source_dir, data_dir)
  39. else
  40. 16 json_path = File.join(source_dir, layouts_dir)
  41. # Create a folder for each view (e.g., views/home_view/ for HomeView)
  42. 16 swift_path = File.join(source_dir, view_dir, view_folder_name)
  43. 16 viewmodel_path = File.join(source_dir, viewmodel_dir)
  44. 16 data_path = File.join(source_dir, data_dir)
  45. end
  46. # Create directories if they don't exist
  47. 17 FileUtils.mkdir_p(json_path)
  48. 17 FileUtils.mkdir_p(swift_path)
  49. 17 FileUtils.mkdir_p(viewmodel_path)
  50. 17 FileUtils.mkdir_p(data_path)
  51. # Create JSON file
  52. 17 json_file = File.join(json_path, "#{json_file_name}.json")
  53. 17 create_json_template(json_file, view_class_name)
  54. # Create Main View file (add View suffix to class name)
  55. 17 main_kotlin_file = File.join(swift_path, "#{view_class_name}View.kt")
  56. 17 create_main_view_template(main_kotlin_file, view_class_name, json_file_name, subdirectory, package_name)
  57. # Create Generated View file
  58. 17 generated_kotlin_file = File.join(swift_path, "#{view_class_name}GeneratedView.kt")
  59. 17 create_generated_view_template(generated_kotlin_file, view_class_name, json_file_name, subdirectory, package_name)
  60. # Create Data file
  61. 17 data_file = File.join(data_path, "#{view_class_name}Data.kt")
  62. 17 create_data_template(data_file, view_class_name, package_name)
  63. # Create ViewModel file
  64. 17 viewmodel_file = File.join(viewmodel_path, "#{view_class_name}ViewModel.kt")
  65. 17 create_viewmodel_template(viewmodel_file, view_class_name, json_file_name, subdirectory, package_name)
  66. # Update MainActivity if --root option is specified
  67. 17 if @options[:root]
  68. update_main_activity(view_class_name, package_name)
  69. end
  70. 17 puts "Generated Compose view:"
  71. 17 puts " JSON: #{json_file}"
  72. 17 puts " Main View: #{main_kotlin_file}"
  73. 17 puts " Generated View: #{generated_kotlin_file}"
  74. 17 puts " Data: #{data_file}"
  75. 17 puts " ViewModel: #{viewmodel_file}"
  76. 17 if @options[:root]
  77. puts " Updated MainActivity to use #{view_class_name}View as root"
  78. end
  79. 17 puts ""
  80. 17 puts "Next steps:"
  81. 17 puts " 1. Edit the JSON layout in #{json_file}"
  82. 17 puts " 2. Run 'kjui build' to generate the Compose code"
  83. end
  84. 1 private
  85. 1 def to_pascal_case(str)
  86. # Handle camelCase and PascalCase input
  87. # First convert to snake_case, then to PascalCase
  88. 17 snake = str.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
  89. .gsub(/([a-z\d])([A-Z])/, '\1_\2')
  90. .downcase
  91. 17 snake.split(/[_\-]/).map(&:capitalize).join
  92. end
  93. 1 def to_snake_case(str)
  94. 70 str.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
  95. .gsub(/([a-z\d])([A-Z])/, '\1_\2')
  96. .downcase
  97. end
  98. 1 def create_json_template(file_path, view_name)
  99. 17 return if File.exist?(file_path)
  100. template = {
  101. 16 type: "SafeAreaView",
  102. background: "#FFFFFF",
  103. child: [
  104. {
  105. type: "View",
  106. orientation: "vertical",
  107. padding: 16,
  108. child: [
  109. {
  110. type: "Label",
  111. text: "@{title}",
  112. fontSize: 24,
  113. fontWeight: "bold",
  114. fontColor: "#000000",
  115. marginBottom: 20
  116. },
  117. {
  118. type: "Label",
  119. text: "Welcome to #{view_name}",
  120. fontSize: 16,
  121. fontColor: "#666666",
  122. marginBottom: 30
  123. },
  124. {
  125. type: "Button",
  126. text: "Get Started",
  127. onclick: "onGetStarted",
  128. background: "#6200EE",
  129. fontColor: "#FFFFFF",
  130. padding: [12, 24],
  131. cornerRadius: 8
  132. }
  133. ]
  134. }
  135. ],
  136. data: [
  137. {
  138. name: "title",
  139. class: "String",
  140. defaultValue: "'#{view_name}'"
  141. }
  142. ]
  143. }
  144. 16 File.write(file_path, JSON.pretty_generate(template))
  145. 16 puts "Created JSON template: #{file_path}"
  146. end
  147. 1 def create_main_view_template(file_path, view_name, json_name, subdirectory, package_name)
  148. 17 return if File.exist?(file_path)
  149. 17 package_parts = package_name.split('.')
  150. # Each view has its own package (e.g., com.example.views.home_view)
  151. # Must use snake_case for subdirectory in package names
  152. 17 view_folder_name = to_snake_case(view_name)
  153. 18 snake_subdir = subdirectory&.split('/')&.map { |p| to_snake_case(p) }&.join('.')
  154. 17 view_package = snake_subdir ? "#{package_name}.views.#{snake_subdir}.#{view_folder_name}" : "#{package_name}.views.#{view_folder_name}"
  155. 17 template = <<~KOTLIN
  156. package #{view_package}
  157. import androidx.compose.runtime.Composable
  158. import androidx.compose.runtime.collectAsState
  159. import androidx.compose.runtime.getValue
  160. import androidx.lifecycle.viewmodel.compose.viewModel
  161. import #{package_name}.viewmodels.#{view_name}ViewModel
  162. @Composable
  163. fun #{view_name}View(
  164. viewModel: #{view_name}ViewModel = viewModel()
  165. ) {
  166. val data by viewModel.data.collectAsState()
  167. #{view_name}GeneratedView(data = data, viewModel = viewModel)
  168. }
  169. KOTLIN
  170. 17 File.write(file_path, template)
  171. 17 puts "Created Main View template: #{file_path}"
  172. end
  173. 1 def create_generated_view_template(file_path, view_name, json_name, subdirectory, package_name)
  174. 17 return if File.exist?(file_path)
  175. 17 json_reference = subdirectory ? "#{subdirectory}/#{json_name}" : json_name
  176. # Each view has its own package (using snake_case for folder)
  177. # Must use snake_case for subdirectory in package names
  178. 17 view_folder_name = to_snake_case(view_name)
  179. 18 snake_subdir = subdirectory&.split('/')&.map { |p| to_snake_case(p) }&.join('.')
  180. 17 view_package = snake_subdir ? "#{package_name}.views.#{snake_subdir}.#{view_folder_name}" : "#{package_name}.views.#{view_folder_name}"
  181. 17 template = <<~KOTLIN
  182. package #{view_package}
  183. import androidx.compose.foundation.background
  184. import androidx.compose.foundation.layout.*
  185. import androidx.compose.foundation.lazy.LazyColumn
  186. import androidx.compose.foundation.lazy.LazyRow
  187. import androidx.compose.material3.*
  188. import androidx.compose.runtime.Composable
  189. import androidx.compose.ui.Alignment
  190. import androidx.compose.ui.Modifier
  191. import androidx.compose.ui.graphics.Color
  192. import androidx.compose.ui.text.font.FontWeight
  193. import androidx.compose.ui.text.style.TextAlign
  194. import androidx.compose.ui.unit.dp
  195. import androidx.compose.ui.unit.sp
  196. import #{package_name}.data.#{view_name}Data
  197. import #{package_name}.viewmodels.#{view_name}ViewModel
  198. @Composable
  199. fun #{view_name}GeneratedView(
  200. data: #{view_name}Data,
  201. viewModel: #{view_name}ViewModel
  202. ) {
  203. // Generated Compose code from #{json_reference}.json
  204. // This will be updated when you run 'kjui build'
  205. // >>> GENERATED_CODE_START
  206. Column(
  207. modifier = Modifier
  208. .fillMaxSize()
  209. .padding(16.dp),
  210. horizontalAlignment = Alignment.CenterHorizontally
  211. ) {
  212. Text(
  213. text = data.title,
  214. fontSize = 24.sp,
  215. fontWeight = FontWeight.Bold
  216. )
  217. Spacer(modifier = Modifier.height(16.dp))
  218. Text(
  219. text = "Run 'kjui build' to generate Compose code",
  220. fontSize = 14.sp,
  221. color = Color.Gray
  222. )
  223. }
  224. // >>> GENERATED_CODE_END
  225. }
  226. KOTLIN
  227. 17 File.write(file_path, template)
  228. 17 puts "Created Generated View template: #{file_path}"
  229. end
  230. 1 def create_data_template(file_path, view_name, package_name)
  231. 17 return if File.exist?(file_path)
  232. 17 data_package = "#{package_name}.data"
  233. 17 template = <<~KOTLIN
  234. package #{data_package}
  235. data class #{view_name}Data(
  236. var title: String = "#{view_name}",
  237. // Action closures (called from generated views)
  238. var onGetStarted: (() -> Unit)? = null
  239. // Add more data properties as needed based on your JSON structure
  240. ) {
  241. // Update properties from map
  242. fun update(map: Map<String, Any>) {
  243. map["title"]?.let {
  244. if (it is String) title = it
  245. }
  246. }
  247. // Convert to map for dynamic mode
  248. fun toMap(): Map<String, Any> {
  249. return mutableMapOf(
  250. "title" to title
  251. )
  252. }
  253. }
  254. KOTLIN
  255. 17 File.write(file_path, template)
  256. 17 puts "Created Data template: #{file_path}"
  257. end
  258. 1 def create_viewmodel_template(file_path, view_name, json_name, subdirectory, package_name)
  259. 17 return if File.exist?(file_path)
  260. 17 json_reference = subdirectory ? "#{subdirectory}/#{json_name}" : json_name
  261. 17 viewmodel_package = "#{package_name}.viewmodels"
  262. 17 template = <<~KOTLIN
  263. // Generated by kjui_tools - DO NOT EDIT between GENERATED_CODE markers
  264. package #{viewmodel_package}
  265. import android.app.Application
  266. import androidx.lifecycle.AndroidViewModel
  267. import kotlinx.coroutines.flow.MutableStateFlow
  268. import kotlinx.coroutines.flow.StateFlow
  269. import kotlinx.coroutines.flow.asStateFlow
  270. import kotlinx.coroutines.flow.update
  271. import #{package_name}.data.#{view_name}Data
  272. class #{view_name}ViewModel(application: Application) : AndroidViewModel(application) {
  273. // JSON file reference for hot reload
  274. val jsonFileName = "#{json_reference}"
  275. // Data model
  276. private val _data = MutableStateFlow(#{view_name}Data())
  277. val data: StateFlow<#{view_name}Data> = _data.asStateFlow()
  278. // >>> GENERATED_CODE_START
  279. // Auto-generated updateData function - updated by 'kjui build'
  280. fun updateData(updates: Map<String, Any>) {
  281. _data.update { current ->
  282. var updated = current
  283. updates.forEach { (key, value) ->
  284. updated = when (key) {
  285. "title" -> updated.copy(title = value as? String ?: updated.title)
  286. else -> updated
  287. }
  288. }
  289. updated
  290. }
  291. }
  292. // >>> GENERATED_CODE_END
  293. // Add your custom action handlers below
  294. fun onGetStarted() {
  295. // Handle button tap
  296. }
  297. }
  298. KOTLIN
  299. 17 File.write(file_path, template)
  300. 17 puts "Created ViewModel template: #{file_path}"
  301. end
  302. 1 def update_main_activity(view_name, package_name)
  303. source_dir = @config['source_directory'] || 'src/main'
  304. # Find MainActivity file
  305. activity_files = Dir.glob(File.join(source_dir, '**/MainActivity.kt'))
  306. if activity_files.empty?
  307. puts "Warning: Could not find MainActivity.kt file to update"
  308. return
  309. end
  310. activity_file = activity_files.first
  311. content = File.read(activity_file)
  312. # Add required imports for DynamicModeManager and view
  313. view_folder_name = to_snake_case(view_name)
  314. required_imports = [
  315. "import com.kotlinjsonui.core.DynamicModeManager",
  316. "import #{package_name}.views.#{view_folder_name}.#{view_name}View"
  317. ]
  318. required_imports.each do |import_line|
  319. unless content.include?(import_line)
  320. # Find the last import line and add after it
  321. if content =~ /(^import .+$)/m
  322. last_import_match = content.scan(/^import .+$/).last
  323. if last_import_match
  324. content.sub!(last_import_match, "#{last_import_match}\n#{import_line}")
  325. end
  326. end
  327. end
  328. end
  329. # Add DynamicModeManager.setDynamicModeEnabled after super.onCreate if not present
  330. unless content.include?("DynamicModeManager.setDynamicModeEnabled")
  331. if content =~ /(super\.onCreate\(savedInstanceState\))/
  332. content.sub!($1, "#{$1}\n\n // Enable Dynamic Mode for HotLoader (debug builds only)\n DynamicModeManager.setDynamicModeEnabled(this, true)")
  333. end
  334. end
  335. # Update setContent block with DynamicModeManager integration
  336. updated = false
  337. # Find and replace the entire setContent block
  338. if content =~ /setContent\s*\{[\s\S]*?\n\s{8}\}/m
  339. content.gsub!(/setContent\s*\{[\s\S]*?\n\s{8}\}/m) do
  340. <<~KOTLIN.chomp
  341. setContent {
  342. val isDynamicModeEnabled by DynamicModeManager.isDynamicModeEnabled.collectAsState()
  343. KotlinJsonUITheme {
  344. Surface(
  345. modifier = Modifier.fillMaxSize(),
  346. color = MaterialTheme.colorScheme.background
  347. ) {
  348. key(isDynamicModeEnabled) {
  349. #{view_name}View()
  350. }
  351. }
  352. }
  353. }
  354. KOTLIN
  355. end
  356. updated = true
  357. elsif content =~ /setContent\s*\{[^}]*\}/m
  358. content.gsub!(/setContent\s*\{[^}]*\}/m) do
  359. <<~KOTLIN.chomp
  360. setContent {
  361. val isDynamicModeEnabled by DynamicModeManager.isDynamicModeEnabled.collectAsState()
  362. key(isDynamicModeEnabled) {
  363. #{view_name}View()
  364. }
  365. }
  366. KOTLIN
  367. end
  368. updated = true
  369. end
  370. if updated
  371. File.write(activity_file, content)
  372. puts "Updated MainActivity to use #{view_name}View as root with DynamicModeManager"
  373. else
  374. puts "Warning: Could not update MainActivity automatically"
  375. puts "Please manually update your MainActivity to use #{view_name}View()"
  376. end
  377. end
  378. end
  379. end
  380. end
  381. end

lib/compose/helpers/import_manager.rb

100.0% lines covered

23 relevant lines. 23 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module KjuiTools
  3. 1 module Compose
  4. 1 module Helpers
  5. 1 class ImportManager
  6. 1 def self.get_imports_map(package_name = nil)
  7. # Use provided package name or default to sample app
  8. 15 pkg_name = package_name || 'com.example.kotlinjsonui.sample'
  9. {
  10. 15 lazy_column: "import androidx.compose.foundation.lazy.LazyColumn",
  11. lazy_row: "import androidx.compose.foundation.lazy.LazyRow",
  12. background: "import androidx.compose.foundation.background",
  13. border: "import androidx.compose.foundation.border",
  14. shape: ["import androidx.compose.foundation.shape.RoundedCornerShape",
  15. "import androidx.compose.ui.draw.clip"],
  16. text_align: "import androidx.compose.ui.text.style.TextAlign",
  17. text_overflow: "import androidx.compose.ui.text.style.TextOverflow",
  18. text_style: "import androidx.compose.ui.text.TextStyle",
  19. font_weight: "import androidx.compose.ui.text.font.FontWeight",
  20. font_family: ["import androidx.compose.ui.text.font.Font",
  21. "import androidx.compose.ui.text.font.FontFamily"],
  22. visual_transformation: "import androidx.compose.ui.text.input.PasswordVisualTransformation",
  23. shadow: "import androidx.compose.ui.draw.shadow",
  24. arrangement: "import androidx.compose.foundation.layout.Arrangement",
  25. keyboard_type: ["import androidx.compose.foundation.text.KeyboardOptions",
  26. "import androidx.compose.ui.text.input.KeyboardType"],
  27. ime_action: "import androidx.compose.ui.text.input.ImeAction",
  28. ime_padding: "import androidx.compose.foundation.layout.imePadding",
  29. button_colors: "import androidx.compose.material3.ButtonDefaults",
  30. button_padding: "import androidx.compose.foundation.layout.PaddingValues",
  31. padding_values: "import androidx.compose.foundation.layout.PaddingValues",
  32. text_decoration: "import androidx.compose.ui.text.style.TextDecoration",
  33. shadow_style: ["import androidx.compose.ui.text.TextStyle",
  34. "import androidx.compose.ui.graphics.Shadow",
  35. "import androidx.compose.ui.geometry.Offset"],
  36. switch_colors: "import androidx.compose.material3.SwitchDefaults",
  37. slider_colors: "import androidx.compose.material3.SliderDefaults",
  38. checkbox_colors: "import androidx.compose.material3.CheckboxDefaults",
  39. dropdown_menu: ["import androidx.compose.material3.DropdownMenu",
  40. "import androidx.compose.material3.DropdownMenuItem",
  41. "import androidx.compose.material.icons.Icons",
  42. "import androidx.compose.material.icons.filled.ArrowDropDown",
  43. "import androidx.compose.foundation.clickable"],
  44. outlined_text_field: "import androidx.compose.material3.OutlinedTextField",
  45. icons: ["import androidx.compose.material.icons.Icons",
  46. "import androidx.compose.material.icons.filled.*",
  47. "import androidx.compose.material.icons.outlined.*"],
  48. icon_button: "import androidx.compose.material3.IconButton",
  49. clickable: "import androidx.compose.foundation.clickable",
  50. radio_colors: "import androidx.compose.material3.RadioButtonDefaults",
  51. tab_row: ["import androidx.compose.material3.TabRow",
  52. "import androidx.compose.material3.Tab"],
  53. async_image: "import coil.compose.AsyncImage",
  54. content_scale: "import androidx.compose.ui.layout.ContentScale",
  55. lazy_grid: ["import androidx.compose.foundation.lazy.grid.LazyVerticalGrid",
  56. "import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid",
  57. "import androidx.compose.foundation.lazy.grid.GridCells",
  58. "import androidx.compose.ui.Alignment"],
  59. grid_item_span: "import androidx.compose.foundation.lazy.grid.GridItemSpan",
  60. webview: ["import android.webkit.WebView",
  61. "import android.webkit.WebViewClient",
  62. "import android.webkit.WebChromeClient",
  63. "import androidx.compose.ui.viewinterop.AndroidView"],
  64. constraint_layout: ["import androidx.constraintlayout.compose.ConstraintLayout",
  65. "import androidx.constraintlayout.compose.Dimension"],
  66. remember_state: ["import androidx.compose.runtime.remember",
  67. "import androidx.compose.runtime.mutableStateOf",
  68. "import androidx.compose.runtime.getValue",
  69. "import androidx.compose.runtime.setValue"],
  70. remember: "import androidx.compose.runtime.remember",
  71. LaunchedEffect: "import androidx.compose.runtime.LaunchedEffect",
  72. launched_effect: "import androidx.compose.runtime.LaunchedEffect",
  73. disposable_effect: "import androidx.compose.runtime.DisposableEffect",
  74. bias_alignment: "import androidx.compose.ui.BiasAlignment",
  75. circle_shape: "import androidx.compose.foundation.shape.CircleShape",
  76. alpha: "import androidx.compose.ui.draw.alpha",
  77. image: "import androidx.compose.foundation.Image",
  78. painter_class: ["import androidx.compose.ui.graphics.painter.Painter",
  79. "import androidx.compose.ui.geometry.Size",
  80. "import androidx.compose.ui.graphics.drawscope.DrawScope"],
  81. painter_resource: "import androidx.compose.ui.res.painterResource",
  82. string_resource: "import androidx.compose.ui.res.stringResource",
  83. color_resource: "import androidx.compose.ui.res.colorResource",
  84. r_class: "import #{pkg_name}.R",
  85. gradient: "import androidx.compose.ui.graphics.Brush",
  86. blur: "import androidx.compose.ui.draw.blur",
  87. navigation: ["import androidx.navigation.NavController",
  88. "import androidx.navigation.compose.NavHost",
  89. "import androidx.navigation.compose.composable",
  90. "import androidx.navigation.compose.rememberNavController"],
  91. selectbox_component: "import com.kotlinjsonui.components.SelectBox",
  92. date_selectbox_component: "import com.kotlinjsonui.components.DateSelectBox",
  93. simple_date_selectbox_component: "import com.kotlinjsonui.components.SimpleDateSelectBox",
  94. visibility_wrapper: "import com.kotlinjsonui.components.VisibilityWrapper",
  95. custom_textfield: ["import com.kotlinjsonui.components.CustomTextField",
  96. "import com.kotlinjsonui.components.CustomTextFieldWithMargins"],
  97. annotated_string: ["import androidx.compose.ui.text.AnnotatedString",
  98. "import androidx.compose.ui.text.buildAnnotatedString",
  99. "import androidx.compose.ui.text.SpanStyle",
  100. "import androidx.compose.ui.text.withStyle"],
  101. clickable_text: "import androidx.compose.foundation.text.ClickableText",
  102. partial_attributes_text: ["import com.kotlinjsonui.components.PartialAttributesText",
  103. "import com.kotlinjsonui.components.PartialAttribute"],
  104. segment: "import com.kotlinjsonui.components.Segment",
  105. dynamic_mode_manager: "import com.kotlinjsonui.core.DynamicModeManager",
  106. configuration: "import com.kotlinjsonui.core.Configuration",
  107. safe_dynamic_view: "import com.kotlinjsonui.components.SafeDynamicView",
  108. circular_progress_indicator: "import androidx.compose.material3.CircularProgressIndicator",
  109. wrapContentSize: "import androidx.compose.foundation.layout.wrapContentSize",
  110. box: "import androidx.compose.foundation.layout.Box",
  111. DynamicView: "import com.kotlinjsonui.dynamic.DynamicView",
  112. JsonObject: "import com.google.gson.JsonObject",
  113. JsonParser: "import com.google.gson.JsonParser",
  114. dashed_border: ["import com.kotlinjsonui.dynamic.helpers.dashedBorder",
  115. "import com.kotlinjsonui.dynamic.helpers.dottedBorder"],
  116. border_stroke: "import androidx.compose.foundation.BorderStroke",
  117. safe_area_config: ["import com.kotlinjsonui.dynamic.LocalSafeAreaConfig",
  118. "import com.kotlinjsonui.dynamic.SafeAreaConfig"],
  119. composition_local_provider: "import androidx.compose.runtime.CompositionLocalProvider"
  120. }
  121. end
  122. 1 def self.update_imports(content, required_imports)
  123. 6 imports_map = get_imports_map
  124. 6 required_imports.each do |import_key|
  125. 6 import_lines = imports_map[import_key]
  126. 6 next unless import_lines
  127. 5 if import_lines.is_a?(Array)
  128. 1 import_lines.each do |import_line|
  129. 2 unless content.include?(import_line)
  130. # Add import after the last import statement
  131. 2 if content =~ /^(import .+\n)+/m
  132. 2 last_import_end = $~.end(0)
  133. 2 content.insert(last_import_end, "#{import_line}\n")
  134. end
  135. end
  136. end
  137. else
  138. 4 unless content.include?(import_lines)
  139. # Add import after the last import statement
  140. 3 if content =~ /^(import .+\n)+/m
  141. 3 last_import_end = $~.end(0)
  142. 3 content.insert(last_import_end, "#{import_lines}\n")
  143. end
  144. end
  145. end
  146. end
  147. 6 content
  148. end
  149. end
  150. end
  151. end
  152. end

lib/compose/helpers/modifier_builder.rb

71.76% lines covered

393 relevant lines. 282 lines covered and 111 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative 'resource_resolver'
  3. 1 module KjuiTools
  4. 1 module Compose
  5. 1 module Helpers
  6. # Helper class to build Compose modifiers from JSON attributes
  7. 1 class ModifierBuilder
  8. 1 def self.build_padding(json_data)
  9. 400 modifiers = []
  10. # Handle padding attribute (can be array [top, right, bottom, left] or single value)
  11. 400 if json_data['padding']
  12. 8 if json_data['padding'].is_a?(Array)
  13. 1 pad_values = json_data['padding']
  14. 1 if pad_values.length == 4
  15. 1 modifiers << ".padding(top = #{pad_values[0]}.dp, end = #{pad_values[1]}.dp, bottom = #{pad_values[2]}.dp, start = #{pad_values[3]}.dp)"
  16. elsif pad_values.length == 1
  17. modifiers << ".padding(#{pad_values[0]}.dp)"
  18. end
  19. else
  20. 7 modifiers << ".padding(#{json_data['padding']}.dp)"
  21. end
  22. end
  23. # Handle paddings attribute (same as padding)
  24. 400 if json_data['paddings']
  25. 1 if json_data['paddings'].is_a?(Array)
  26. pad_values = json_data['paddings']
  27. if pad_values.length == 4
  28. modifiers << ".padding(top = #{pad_values[0]}.dp, end = #{pad_values[1]}.dp, bottom = #{pad_values[2]}.dp, start = #{pad_values[3]}.dp)"
  29. elsif pad_values.length == 1
  30. modifiers << ".padding(#{pad_values[0]}.dp)"
  31. end
  32. else
  33. 1 modifiers << ".padding(#{json_data['paddings']}.dp)"
  34. end
  35. end
  36. # Individual padding attributes
  37. 400 modifiers << ".padding(top = #{json_data['paddingTop']}.dp)" if json_data['paddingTop']
  38. 400 modifiers << ".padding(bottom = #{json_data['paddingBottom']}.dp)" if json_data['paddingBottom']
  39. 400 modifiers << ".padding(start = #{json_data['paddingLeft']}.dp)" if json_data['paddingLeft']
  40. 400 modifiers << ".padding(end = #{json_data['paddingRight']}.dp)" if json_data['paddingRight']
  41. 400 modifiers
  42. end
  43. 1 def self.build_margins(json_data)
  44. 476 modifiers = []
  45. # Handle margins attribute (can be array [top, right, bottom, left] or single value)
  46. 476 if json_data['margins']
  47. 16 if json_data['margins'].is_a?(Array)
  48. 15 margin_values = json_data['margins']
  49. 15 if margin_values.length == 4
  50. 5 modifiers << ".padding(top = #{margin_values[0]}.dp, end = #{margin_values[1]}.dp, bottom = #{margin_values[2]}.dp, start = #{margin_values[3]}.dp)"
  51. 10 elsif margin_values.length == 1
  52. 5 modifiers << ".padding(#{margin_values[0]}.dp)"
  53. end
  54. else
  55. 1 modifiers << ".padding(#{json_data['margins']}.dp)"
  56. end
  57. end
  58. # Individual margin attributes (with binding support)
  59. 476 modifiers << ".padding(top = #{margin_value(json_data['topMargin'])})" if json_data['topMargin']
  60. 476 modifiers << ".padding(bottom = #{margin_value(json_data['bottomMargin'])})" if json_data['bottomMargin']
  61. 476 modifiers << ".padding(start = #{margin_value(json_data['leftMargin'])})" if json_data['leftMargin']
  62. 476 modifiers << ".padding(end = #{margin_value(json_data['rightMargin'])})" if json_data['rightMargin']
  63. # RTL aware margins
  64. 476 modifiers << ".padding(start = #{margin_value(json_data['startMargin'])})" if json_data['startMargin']
  65. 476 modifiers << ".padding(end = #{margin_value(json_data['endMargin'])})" if json_data['endMargin']
  66. 476 modifiers
  67. end
  68. # Convert margin value to Kotlin/Compose format with binding support
  69. 1 def self.margin_value(value)
  70. 7 if is_binding?(value)
  71. # Data binding: @{propertyName} -> data.propertyName.dp
  72. property = extract_binding_property(value)
  73. "data.#{property}.dp"
  74. else
  75. 7 "#{value}.dp"
  76. end
  77. end
  78. 1 def self.build_weight(json_data, parent_orientation = nil)
  79. 333 modifiers = []
  80. # Weight only works in Row/Column contexts
  81. # Weight must be greater than 0 in Compose
  82. 333 if json_data['weight'] && parent_orientation && json_data['weight'].to_f > 0
  83. 5 modifiers << ".weight(#{json_data['weight']}f)"
  84. end
  85. 333 modifiers
  86. end
  87. 1 def self.build_size(json_data)
  88. 350 modifiers = []
  89. # Handle 'frame' attribute - object with width/height
  90. # frame: { width: 100, height: 50 }
  91. 350 if json_data['frame'].is_a?(Hash)
  92. frame = json_data['frame']
  93. if frame['width']
  94. if frame['width'] == 'matchParent'
  95. modifiers << ".fillMaxWidth()"
  96. elsif frame['width'] == 'wrapContent'
  97. modifiers << ".wrapContentWidth()"
  98. else
  99. modifiers << ".width(#{process_dimension(frame['width'])})"
  100. end
  101. end
  102. if frame['height']
  103. if frame['height'] == 'matchParent'
  104. modifiers << ".fillMaxHeight()"
  105. elsif frame['height'] == 'wrapContent'
  106. modifiers << ".wrapContentHeight()"
  107. else
  108. modifiers << ".height(#{process_dimension(frame['height'])})"
  109. end
  110. end
  111. # If frame is specified, skip individual width/height processing
  112. return modifiers
  113. end
  114. # Width - skip if weight is present and width is 0
  115. 350 if json_data['width'] == 'matchParent'
  116. 2 modifiers << ".fillMaxWidth()"
  117. 348 elsif json_data['width'] == 'wrapContent'
  118. 1 modifiers << ".wrapContentWidth()"
  119. 347 elsif json_data['width'] && !(json_data['weight'] && json_data['width'] == 0)
  120. 11 modifiers << ".width(#{process_dimension(json_data['width'])})"
  121. end
  122. # Height - skip if heightWeight is present and height is 0
  123. 350 if json_data['height'] == 'matchParent'
  124. 1 modifiers << ".fillMaxHeight()"
  125. 349 elsif json_data['height'] == 'wrapContent'
  126. modifiers << ".wrapContentHeight()"
  127. 349 elsif json_data['height'] && !(json_data['heightWeight'] && json_data['height'] == 0)
  128. 8 modifiers << ".height(#{process_dimension(json_data['height'])})"
  129. end
  130. # Min/Max constraints
  131. 350 if json_data['minWidth']
  132. 1 modifiers << ".widthIn(min = #{json_data['minWidth']}.dp)"
  133. end
  134. 350 if json_data['maxWidth']
  135. 1 modifiers << ".widthIn(max = #{json_data['maxWidth']}.dp)"
  136. end
  137. 350 if json_data['minHeight']
  138. modifiers << ".heightIn(min = #{json_data['minHeight']}.dp)"
  139. end
  140. 350 if json_data['maxHeight']
  141. modifiers << ".heightIn(max = #{json_data['maxHeight']}.dp)"
  142. end
  143. # Combined min/max if both specified
  144. 350 if json_data['minWidth'] && json_data['maxWidth']
  145. 3 modifiers = modifiers.reject { |m| m.include?('.widthIn') }
  146. 1 modifiers << ".widthIn(min = #{json_data['minWidth']}.dp, max = #{json_data['maxWidth']}.dp)"
  147. end
  148. 350 if json_data['minHeight'] && json_data['maxHeight']
  149. modifiers = modifiers.reject { |m| m.include?('.heightIn') }
  150. modifiers << ".heightIn(min = #{json_data['minHeight']}.dp, max = #{json_data['maxHeight']}.dp)"
  151. end
  152. # Aspect ratio
  153. 350 if json_data['aspectWidth'] && json_data['aspectHeight']
  154. 1 ratio = json_data['aspectWidth'].to_f / json_data['aspectHeight'].to_f
  155. 1 modifiers << ".aspectRatio(#{ratio}f)"
  156. end
  157. 350 modifiers
  158. end
  159. 1 def self.build_shadow(json_data, required_imports = nil)
  160. 47 modifiers = []
  161. 47 if json_data['shadow']
  162. 3 required_imports&.add(:shadow)
  163. 3 if json_data['shadow'].is_a?(String)
  164. # Simple shadow with color
  165. 2 modifiers << ".shadow(4.dp, shape = RectangleShape)"
  166. 1 elsif json_data['shadow'].is_a?(Hash)
  167. # Complex shadow configuration
  168. 1 shadow = json_data['shadow']
  169. 1 elevation = shadow['radius'] || 4
  170. 1 shape = json_data['cornerRadius'] ? "RoundedCornerShape(#{json_data['cornerRadius']}.dp)" : "RectangleShape"
  171. 1 modifiers << ".shadow(#{elevation}.dp, shape = #{shape})"
  172. end
  173. end
  174. 47 modifiers
  175. end
  176. 1 def self.build_background(json_data, required_imports = nil)
  177. 162 modifiers = []
  178. 162 if json_data['background']
  179. 3 required_imports&.add(:background)
  180. # Use ResourceResolver to process background color
  181. 3 background_color = ResourceResolver.process_color(json_data['background'], required_imports)
  182. 3 if json_data['cornerRadius'] || json_data['borderColor'] || json_data['borderWidth']
  183. 1 required_imports&.add(:border)
  184. 1 required_imports&.add(:shape)
  185. 1 if json_data['cornerRadius']
  186. 1 modifiers << ".clip(RoundedCornerShape(#{json_data['cornerRadius']}.dp))"
  187. end
  188. 1 if json_data['borderColor'] && json_data['borderWidth']
  189. modifiers << build_border_modifier(json_data, required_imports)
  190. end
  191. 1 modifiers << ".background(#{background_color})"
  192. else
  193. 2 modifiers << ".background(#{background_color})"
  194. end
  195. 159 elsif json_data['cornerRadius'] || json_data['borderColor'] || json_data['borderWidth']
  196. 1 required_imports&.add(:border)
  197. 1 required_imports&.add(:shape)
  198. 1 if json_data['cornerRadius']
  199. 1 modifiers << ".clip(RoundedCornerShape(#{json_data['cornerRadius']}.dp))"
  200. end
  201. 1 if json_data['borderColor'] && json_data['borderWidth']
  202. 1 modifiers << build_border_modifier(json_data, required_imports)
  203. end
  204. end
  205. 162 modifiers
  206. end
  207. 1 def self.build_visibility(json_data, required_imports = nil)
  208. 117 modifiers = []
  209. 117 visibility_info = {}
  210. # Handle visibility attribute (static or data-bound)
  211. 117 if json_data['visibility']
  212. 5 if json_data['visibility'].is_a?(String) && json_data['visibility'].start_with?('@{')
  213. # Data binding for visibility
  214. 2 variable = json_data['visibility'].gsub('@{', '').gsub('}', '')
  215. 2 visibility_info[:visibility_binding] = "data.#{variable}"
  216. 2 required_imports&.add(:visibility_wrapper)
  217. else
  218. # Static visibility
  219. 3 visibility_info[:visibility] = json_data['visibility']
  220. 3 required_imports&.add(:visibility_wrapper)
  221. end
  222. end
  223. # Handle hidden attribute (boolean or data binding)
  224. 117 if json_data['hidden']
  225. 4 if json_data['hidden'].is_a?(String) && json_data['hidden'].start_with?('@{')
  226. # Data binding for hidden
  227. 2 variable = json_data['hidden'].gsub('@{', '').gsub('}', '')
  228. 2 visibility_info[:hidden_binding] = "data.#{variable}"
  229. 2 required_imports&.add(:visibility_wrapper)
  230. 2 elsif json_data['hidden'] == true
  231. 2 visibility_info[:hidden] = true
  232. 2 required_imports&.add(:visibility_wrapper)
  233. end
  234. end
  235. # Handle alpha/opacity attribute separately (not part of visibility wrapper)
  236. # Support both 'alpha' and 'opacity' for compatibility
  237. 117 alpha_value = json_data['alpha'] || json_data['opacity']
  238. 117 if alpha_value
  239. 2 required_imports&.add(:alpha)
  240. 2 modifiers << ".alpha(#{alpha_value}f)"
  241. end
  242. # Return both visibility info and modifiers
  243. 117 { modifiers: modifiers, visibility_info: visibility_info }
  244. end
  245. 1 def self.build_alignment(json_data, required_imports = nil, parent_type = nil)
  246. 177 modifiers = []
  247. # For Row, only vertical alignment is allowed
  248. 177 if parent_type == 'Row'
  249. 4 if json_data['alignTop']
  250. 1 modifiers << ".align(Alignment.Top)"
  251. 3 elsif json_data['alignBottom']
  252. modifiers << ".align(Alignment.Bottom)"
  253. 3 elsif json_data['centerVertical']
  254. 1 modifiers << ".align(Alignment.CenterVertically)"
  255. end
  256. # For Column, only horizontal alignment is allowed
  257. 173 elsif parent_type == 'Column'
  258. 4 if json_data['alignLeft']
  259. 1 modifiers << ".align(Alignment.Start)"
  260. 3 elsif json_data['alignRight']
  261. modifiers << ".align(Alignment.End)"
  262. 3 elsif json_data['centerHorizontal']
  263. 1 modifiers << ".align(Alignment.CenterHorizontally)"
  264. end
  265. # For Box and other containers, full alignment options
  266. 169 elsif parent_type == 'Box'
  267. # Check if any alignment is specified
  268. 4 has_alignment = json_data['alignTop'] || json_data['alignBottom'] ||
  269. json_data['alignLeft'] || json_data['alignRight'] ||
  270. json_data['centerHorizontal'] || json_data['centerVertical'] ||
  271. json_data['centerInParent']
  272. # First check for both-direction constraints (centering behavior)
  273. 4 has_horizontal_both = json_data['alignLeft'] && json_data['alignRight']
  274. 4 has_vertical_both = json_data['alignTop'] && json_data['alignBottom']
  275. # Handle combined alignments
  276. 4 if has_horizontal_both && has_vertical_both
  277. # Both horizontal and vertical constraints - center completely
  278. modifiers << ".align(Alignment.Center)"
  279. 4 elsif has_horizontal_both && json_data['alignTop']
  280. # Center horizontally, align top
  281. required_imports&.add(:bias_alignment)
  282. modifiers << ".align(BiasAlignment(0f, -1f))"
  283. 4 elsif has_horizontal_both && json_data['alignBottom']
  284. # Center horizontally, align bottom
  285. required_imports&.add(:bias_alignment)
  286. modifiers << ".align(BiasAlignment(0f, 1f))"
  287. 4 elsif has_horizontal_both
  288. # Just center horizontally
  289. required_imports&.add(:bias_alignment)
  290. modifiers << ".align(BiasAlignment(0f, 0f))"
  291. 4 elsif has_vertical_both && json_data['alignLeft']
  292. # Center vertically, align left
  293. modifiers << ".align(Alignment.CenterStart)"
  294. 4 elsif has_vertical_both && json_data['alignRight']
  295. # Center vertically, align right
  296. modifiers << ".align(Alignment.CenterEnd)"
  297. 4 elsif has_vertical_both
  298. # Just center vertically
  299. required_imports&.add(:bias_alignment)
  300. modifiers << ".align(BiasAlignment(0f, 0f))"
  301. 4 elsif json_data['alignTop'] && json_data['alignLeft']
  302. 1 modifiers << ".align(Alignment.TopStart)"
  303. 3 elsif json_data['alignTop'] && json_data['alignRight']
  304. modifiers << ".align(Alignment.TopEnd)"
  305. 3 elsif json_data['alignBottom'] && json_data['alignLeft']
  306. modifiers << ".align(Alignment.BottomStart)"
  307. 3 elsif json_data['alignBottom'] && json_data['alignRight']
  308. 1 modifiers << ".align(Alignment.BottomEnd)"
  309. 2 elsif json_data['alignTop'] && json_data['centerHorizontal']
  310. # TopCenter doesn't exist in BoxScope, use BiasAlignment
  311. 1 required_imports&.add(:bias_alignment)
  312. 1 modifiers << ".align(BiasAlignment(0f, -1f))"
  313. 1 elsif json_data['alignBottom'] && json_data['centerHorizontal']
  314. # BottomCenter doesn't exist in BoxScope, use BiasAlignment
  315. required_imports&.add(:bias_alignment)
  316. modifiers << ".align(BiasAlignment(0f, 1f))"
  317. 1 elsif json_data['alignLeft'] && json_data['centerVertical']
  318. modifiers << ".align(Alignment.CenterStart)"
  319. 1 elsif json_data['alignRight'] && json_data['centerVertical']
  320. modifiers << ".align(Alignment.CenterEnd)"
  321. 1 elsif json_data['centerInParent']
  322. 1 modifiers << ".align(Alignment.Center)"
  323. # Handle single alignments for Box
  324. elsif json_data['alignTop']
  325. # Just top alignment - align to top-left
  326. required_imports&.add(:bias_alignment)
  327. modifiers << ".align(BiasAlignment(-1f, -1f))"
  328. elsif json_data['alignBottom']
  329. # Just bottom alignment - align to bottom-left
  330. required_imports&.add(:bias_alignment)
  331. modifiers << ".align(BiasAlignment(-1f, 1f))"
  332. elsif json_data['alignLeft']
  333. # Just left alignment - align to top-left
  334. required_imports&.add(:bias_alignment)
  335. modifiers << ".align(BiasAlignment(-1f, -1f))"
  336. elsif json_data['alignRight']
  337. # Just right alignment - align to top-right
  338. required_imports&.add(:bias_alignment)
  339. modifiers << ".align(BiasAlignment(1f, -1f))"
  340. elsif json_data['centerHorizontal']
  341. # Center horizontally only - align to top-center
  342. required_imports&.add(:bias_alignment)
  343. modifiers << ".align(BiasAlignment(0f, -1f))"
  344. elsif json_data['centerVertical']
  345. # Center vertically only - align to center-left
  346. required_imports&.add(:bias_alignment)
  347. modifiers << ".align(BiasAlignment(-1f, 0f))"
  348. elsif !has_alignment
  349. # No alignment specified - default to TopStart (top-left)
  350. modifiers << ".align(Alignment.TopStart)"
  351. end
  352. end
  353. 177 modifiers
  354. end
  355. 1 def self.build_relative_positioning(json_data)
  356. # These attributes require ConstraintLayout
  357. # They generate constraint references instead of modifiers
  358. 8 constraints = []
  359. # Extract margins for use in constraints (with binding support)
  360. 8 top_margin = constraint_margin_value(json_data['topMargin'])
  361. 8 bottom_margin = constraint_margin_value(json_data['bottomMargin'])
  362. 8 start_margin = constraint_margin_value(json_data['leftMargin'])
  363. 8 end_margin = constraint_margin_value(json_data['rightMargin'])
  364. 8 if json_data['margins'] && json_data['margins'].is_a?(Array) && json_data['margins'].length == 4
  365. top_margin = json_data['margins'][0].to_s + ".dp" unless json_data['topMargin']
  366. end_margin = json_data['margins'][1].to_s + ".dp" unless json_data['rightMargin']
  367. bottom_margin = json_data['margins'][2].to_s + ".dp" unless json_data['bottomMargin']
  368. start_margin = json_data['margins'][3].to_s + ".dp" unless json_data['leftMargin']
  369. end
  370. # Relative to other views
  371. 8 if json_data['alignTopOfView']
  372. 2 margin = has_constraint_margin?(bottom_margin) ? ", margin = #{bottom_margin}" : ""
  373. 2 constraints << "bottom.linkTo(#{json_data['alignTopOfView']}.top#{margin})"
  374. end
  375. 8 if json_data['alignBottomOfView']
  376. margin = has_constraint_margin?(top_margin) ? ", margin = #{top_margin}" : ""
  377. constraints << "top.linkTo(#{json_data['alignBottomOfView']}.bottom#{margin})"
  378. end
  379. 8 if json_data['alignLeftOfView']
  380. margin = has_constraint_margin?(end_margin) ? ", margin = #{end_margin}" : ""
  381. constraints << "end.linkTo(#{json_data['alignLeftOfView']}.start#{margin})"
  382. end
  383. 8 if json_data['alignRightOfView']
  384. margin = has_constraint_margin?(start_margin) ? ", margin = #{start_margin}" : ""
  385. constraints << "start.linkTo(#{json_data['alignRightOfView']}.end#{margin})"
  386. end
  387. # Align edges with other views
  388. # For align operations, use negative margins to move in the expected direction
  389. 8 if json_data['alignTopView']
  390. # alignTop with topMargin means move DOWN from the aligned position
  391. # linkTo margin pushes away, so use negative to pull closer (move down)
  392. margin = has_constraint_margin?(top_margin) ? ", margin = (-#{top_margin})" : ""
  393. constraints << "top.linkTo(#{json_data['alignTopView']}.top#{margin})"
  394. end
  395. 8 if json_data['alignBottomView']
  396. # alignBottom with bottomMargin means move UP from the aligned position
  397. # linkTo margin pushes away, so use negative to pull closer (move up)
  398. margin = has_constraint_margin?(bottom_margin) ? ", margin = (-#{bottom_margin})" : ""
  399. constraints << "bottom.linkTo(#{json_data['alignBottomView']}.bottom#{margin})"
  400. end
  401. 8 if json_data['alignLeftView']
  402. # alignLeft with leftMargin means move RIGHT from the aligned position
  403. # linkTo margin pushes away, so use negative to pull closer (move right)
  404. margin = has_constraint_margin?(start_margin) ? ", margin = (-#{start_margin})" : ""
  405. constraints << "start.linkTo(#{json_data['alignLeftView']}.start#{margin})"
  406. end
  407. 8 if json_data['alignRightView']
  408. # alignRight with rightMargin means move LEFT from the aligned position
  409. # linkTo margin pushes away, so use negative to pull closer (move left)
  410. margin = has_constraint_margin?(end_margin) ? ", margin = (-#{end_margin})" : ""
  411. constraints << "end.linkTo(#{json_data['alignRightView']}.end#{margin})"
  412. end
  413. # Center with other views
  414. 8 if json_data['alignCenterVerticalView']
  415. constraints << "top.linkTo(#{json_data['alignCenterVerticalView']}.top)"
  416. constraints << "bottom.linkTo(#{json_data['alignCenterVerticalView']}.bottom)"
  417. end
  418. 8 if json_data['alignCenterHorizontalView']
  419. constraints << "start.linkTo(#{json_data['alignCenterHorizontalView']}.start)"
  420. constraints << "end.linkTo(#{json_data['alignCenterHorizontalView']}.end)"
  421. end
  422. # Parent constraints
  423. # For parent alignment, margins should work normally as offsets
  424. 8 if json_data['alignTop']
  425. 4 margin = has_constraint_margin?(top_margin) ? ", margin = #{top_margin}" : ""
  426. 4 constraints << "top.linkTo(parent.top#{margin})"
  427. end
  428. 8 if json_data['alignBottom']
  429. margin = has_constraint_margin?(bottom_margin) ? ", margin = #{bottom_margin}" : ""
  430. constraints << "bottom.linkTo(parent.bottom#{margin})"
  431. end
  432. 8 if json_data['alignLeft']
  433. 2 margin = has_constraint_margin?(start_margin) ? ", margin = #{start_margin}" : ""
  434. 2 constraints << "start.linkTo(parent.start#{margin})"
  435. end
  436. 8 if json_data['alignRight']
  437. margin = has_constraint_margin?(end_margin) ? ", margin = #{end_margin}" : ""
  438. constraints << "end.linkTo(parent.end#{margin})"
  439. end
  440. 8 if json_data['centerHorizontal']
  441. constraints << "start.linkTo(parent.start)"
  442. constraints << "end.linkTo(parent.end)"
  443. end
  444. 8 if json_data['centerVertical']
  445. constraints << "top.linkTo(parent.top)"
  446. constraints << "bottom.linkTo(parent.bottom)"
  447. end
  448. 8 if json_data['centerInParent']
  449. 2 constraints << "top.linkTo(parent.top)"
  450. 2 constraints << "bottom.linkTo(parent.bottom)"
  451. 2 constraints << "start.linkTo(parent.start)"
  452. 2 constraints << "end.linkTo(parent.end)"
  453. end
  454. 8 constraints
  455. end
  456. 1 def self.format(modifiers, depth)
  457. 175 return "" if modifiers.empty?
  458. # Check if first modifier is already "Modifier"
  459. 100 if modifiers[0] == "Modifier"
  460. 3 code = "\n" + indent("modifier = Modifier", depth + 1)
  461. # Skip the first "Modifier" and process the rest
  462. 3 modifiers[1..-1].each do |mod|
  463. 15 code += "\n" + indent(" #{mod}", depth + 1)
  464. end
  465. else
  466. 97 code = "\n" + indent("modifier = Modifier", depth + 1)
  467. 97 if modifiers.length == 1 && modifiers[0].start_with?('.')
  468. 71 code += modifiers[0]
  469. else
  470. 26 modifiers.each do |mod|
  471. 55 code += "\n" + indent(" #{mod}", depth + 1)
  472. end
  473. end
  474. end
  475. 100 code
  476. end
  477. # Build lifecycle event effects (onAppear/onDisappear)
  478. # Returns a hash with :before (code before content) and :after (code after content)
  479. 1 def self.build_lifecycle_effects(json_data, depth, required_imports = nil)
  480. 16 result = { before: "", after: "" }
  481. 16 if json_data['onAppear']
  482. 9 required_imports&.add(:launched_effect)
  483. 9 handler = json_data['onAppear']
  484. # Strip @{} binding syntax if present
  485. 9 property = is_binding?(handler) ? extract_binding_property(handler) : handler
  486. # Also strip : prefix if present
  487. 9 property = property.gsub(':', '') if property.include?(':')
  488. 9 result[:before] += indent("// onAppear lifecycle event", depth)
  489. 9 result[:before] += "\n" + indent("LaunchedEffect(Unit) {", depth)
  490. 9 result[:before] += "\n" + indent("data.#{property}?.invoke()", depth + 1)
  491. 9 result[:before] += "\n" + indent("}", depth)
  492. 9 result[:before] += "\n"
  493. end
  494. 16 if json_data['onDisappear']
  495. 7 required_imports&.add(:disposable_effect)
  496. 7 handler = json_data['onDisappear']
  497. # Strip @{} binding syntax if present
  498. 7 property = is_binding?(handler) ? extract_binding_property(handler) : handler
  499. # Also strip : prefix if present
  500. 7 property = property.gsub(':', '') if property.include?(':')
  501. 7 result[:before] += indent("// onDisappear lifecycle event", depth)
  502. 7 result[:before] += "\n" + indent("DisposableEffect(Unit) {", depth)
  503. 7 result[:before] += "\n" + indent("onDispose {", depth + 1)
  504. 7 result[:before] += "\n" + indent("data.#{property}?.invoke()", depth + 2)
  505. 7 result[:before] += "\n" + indent("}", depth + 1)
  506. 7 result[:before] += "\n" + indent("}", depth)
  507. 7 result[:before] += "\n"
  508. end
  509. 16 result
  510. end
  511. # Check if component has lifecycle events
  512. 1 def self.has_lifecycle_events?(json_data)
  513. 9 json_data['onAppear'] || json_data['onDisappear']
  514. end
  515. # Convert event handler to method call
  516. # onClick -> binding format only: @{functionName} -> data.functionName?.invoke()
  517. 1 def self.get_event_handler_call(handler, is_camel_case: false)
  518. # Extract function name from binding format @{functionName}
  519. 4 if handler.match?(/^@\{(.+)\}$/)
  520. method_name = handler.match(/^@\{(.+)\}$/)[1]
  521. "data.#{method_name}?.invoke()"
  522. else
  523. # Direct function name (non-binding)
  524. 4 "data.#{handler}?.invoke()"
  525. end
  526. end
  527. # Check if handler is binding format (@{functionName})
  528. 1 def self.is_binding?(value)
  529. 41 value.is_a?(String) && value.match?(/^@\{.+\}$/)
  530. end
  531. # Extract property name from binding expression
  532. # "@{propertyName}" -> "propertyName"
  533. 1 def self.extract_binding_property(value)
  534. 9 return nil unless value.is_a?(String)
  535. 9 if value.match(/^@\{(.+)\}$/)
  536. 9 $1
  537. else
  538. value
  539. end
  540. end
  541. # Convert margin value to Kotlin/Compose format for constraint linkTo() with binding support
  542. # Returns nil for no margin, or the formatted value (e.g., "8.dp" or "data.margin.dp")
  543. 1 def self.constraint_margin_value(value)
  544. 32 return nil if value.nil?
  545. 1 if is_binding?(value)
  546. # Data binding: @{propertyName} -> data.propertyName.dp
  547. property = extract_binding_property(value)
  548. "data.#{property}.dp"
  549. 1 elsif value.is_a?(Numeric) && value > 0
  550. 1 "#{value}.dp"
  551. elsif value.is_a?(String)
  552. # Try to parse as number
  553. num = value.to_i
  554. num > 0 ? "#{num}.dp" : nil
  555. else
  556. nil
  557. end
  558. end
  559. # Check if constraint margin value is present
  560. 1 def self.has_constraint_margin?(margin_value)
  561. 8 return false if margin_value.nil?
  562. 1 return true if margin_value.is_a?(String) && margin_value.length > 0
  563. false
  564. end
  565. 1 private
  566. # Build border modifier with support for solid/dashed/dotted styles
  567. 1 def self.build_border_modifier(json_data, required_imports = nil)
  568. 1 border_color = ResourceResolver.process_color(json_data['borderColor'], required_imports)
  569. 1 border_width = json_data['borderWidth']
  570. 1 border_style = json_data['borderStyle'] || 'solid'
  571. 1 border_shape = json_data['cornerRadius'] ? "RoundedCornerShape(#{json_data['cornerRadius']}.dp)" : "RectangleShape"
  572. 1 case border_style
  573. when 'dashed'
  574. required_imports&.add(:dashed_border)
  575. ".dashedBorder(#{border_width}.dp, #{border_color}, #{border_shape})"
  576. when 'dotted'
  577. required_imports&.add(:dashed_border)
  578. ".dottedBorder(#{border_width}.dp, #{border_color}, #{border_shape})"
  579. else # 'solid' or default
  580. 1 ".border(#{border_width}.dp, #{border_color}, #{border_shape})"
  581. end
  582. end
  583. # Process dimension value - handles data bindings and numeric values
  584. 1 def self.process_dimension(value)
  585. 25 return "#{value}.dp" if value.is_a?(Numeric)
  586. 5 if value.is_a?(String)
  587. # Check for data binding syntax @{variableName}
  588. 4 if value.match(/@\{([^}]+)\}/)
  589. 2 variable = $1
  590. # Data binding returns Int/Float from ViewModel, append .dp
  591. 2 return "data.#{variable}.dp"
  592. end
  593. # Regular string value (might be percentage or other)
  594. 2 return "#{value}.dp"
  595. end
  596. 1 "0.dp"
  597. end
  598. 1 def self.indent(text, level)
  599. 248 return text if level == 0
  600. 245 spaces = ' ' * level
  601. 245 text.split("\n").map { |line|
  602. 245 line.empty? ? line : spaces + line
  603. }.join("\n")
  604. end
  605. end
  606. end
  607. end
  608. end

lib/compose/helpers/resource_resolver.rb

86.18% lines covered

123 relevant lines. 106 lines covered and 17 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'rexml/document'
  3. 1 require 'json'
  4. 1 require_relative '../../core/config_manager'
  5. 1 require_relative '../../core/project_finder'
  6. 1 module KjuiTools
  7. 1 module Compose
  8. 1 module Helpers
  9. 1 class ResourceResolver
  10. 1 class << self
  11. # Thread-local storage for data definitions during build
  12. 1 def data_definitions
  13. 10 Thread.current[:kjui_data_definitions] || {}
  14. end
  15. 1 def data_definitions=(definitions)
  16. 23 Thread.current[:kjui_data_definitions] = definitions
  17. end
  18. # Check if a property has a default value (non-optional)
  19. 1 def has_default_value?(property_name)
  20. 6 return false unless data_definitions[property_name]
  21. 4 !data_definitions[property_name]['defaultValue'].nil?
  22. end
  23. # Don't cache - just load each time to avoid issues
  24. 1 def cached_config
  25. 485 Core::ConfigManager.load_config
  26. end
  27. 1 def cached_source_path
  28. 244 Core::ProjectFinder.get_full_source_path || Dir.pwd
  29. end
  30. # Process text with data binding and resource resolution
  31. 1 def process_text(text, required_imports = nil)
  32. 116 return quote(text) unless text.is_a?(String)
  33. # Handle data binding expressions
  34. 116 if text.match(/@\{([^}]+)\}/)
  35. 4 variable = $1
  36. 4 if variable.include?(' ?? ')
  37. 1 parts = variable.split(' ?? ')
  38. 1 var_name = parts[0].strip
  39. 1 return "\"\${data.#{var_name}}\""
  40. else
  41. # Check if property has defaultValue (non-optional)
  42. 3 if has_default_value?(variable)
  43. 1 return "\"\${data.#{variable}}\""
  44. else
  45. # Optional - add ?: "" fallback
  46. 2 return "\"\${data.#{variable} ?: \"\"}\""
  47. end
  48. end
  49. end
  50. # Skip resource resolution if we're in the extraction phase
  51. # (Resources directory doesn't exist yet)
  52. 112 source_directory = cached_config['source_directory'] || 'src/main'
  53. 112 layouts_dir = File.join(cached_source_path, source_directory, cached_config['layouts_directory'] || 'assets/Layouts')
  54. 112 resources_dir = File.join(layouts_dir, 'Resources')
  55. # If Resources directory doesn't exist, we're in extraction phase
  56. # Just return quoted text
  57. 112 return quote(text) unless File.exist?(resources_dir)
  58. # Try to resolve as a string resource
  59. 1 resolved = resolve_string(text, cached_config, cached_source_path)
  60. 1 if resolved.include?('stringResource')
  61. 1 required_imports&.add(:string_resource)
  62. 1 required_imports&.add(:r_class)
  63. end
  64. 1 resolved
  65. end
  66. # Process color with resource resolution
  67. 1 def process_color(color, required_imports = nil)
  68. 130 return nil unless color.is_a?(String)
  69. # Handle data binding expressions - convert to data property access
  70. 129 if color.start_with?('@{') && color.end_with?('}')
  71. 3 variable = color.gsub(/@\{|\}/, '')
  72. # Check if property has defaultValue (non-optional)
  73. 3 if has_default_value?(variable)
  74. 1 return "data.#{variable}"
  75. else
  76. # Nullable (Color?) - need default value
  77. 2 return "data.#{variable} ?: Color.Unspecified"
  78. end
  79. end
  80. # Skip resource resolution if we're in the extraction phase
  81. # (Resources directory doesn't exist yet)
  82. 126 source_directory = cached_config['source_directory'] || 'src/main'
  83. 126 layouts_dir = File.join(cached_source_path, source_directory, cached_config['layouts_directory'] || 'assets/Layouts')
  84. 126 resources_dir = File.join(layouts_dir, 'Resources')
  85. # If Resources directory doesn't exist, we're in extraction phase
  86. # Just return standard color parsing
  87. 126 unless File.exist?(resources_dir)
  88. 124 return "Color(android.graphics.Color.parseColor(#{quote(color)}))"
  89. end
  90. 2 resolved = resolve_color(color, cached_config, cached_source_path)
  91. 2 if resolved&.include?('colorResource')
  92. 2 required_imports&.add(:color_resource)
  93. 2 required_imports&.add(:r_class)
  94. end
  95. 2 resolved
  96. end
  97. 1 private
  98. # Check if a string resource exists in strings.xml
  99. 1 def resolve_string(text, config, source_path)
  100. 1 return quote(text) unless text.is_a?(String)
  101. # Skip if it's a data binding expression
  102. 1 return quote(text) if text.start_with?('@{') || text.start_with?('${')
  103. # Try to find the string in strings.xml
  104. 1 string_key = find_string_key(text, config, source_path)
  105. 1 if string_key
  106. # Return stringResource reference
  107. 1 "stringResource(R.string.#{string_key})"
  108. else
  109. # Return quoted string
  110. quote(text)
  111. end
  112. end
  113. # Check if a color resource exists
  114. 1 def resolve_color(color, config, source_path)
  115. 2 return nil unless color.is_a?(String)
  116. # Skip if it's a data binding expression
  117. 2 return "Color(android.graphics.Color.parseColor(#{quote(color)}))" if color.start_with?('@{') || color.start_with?('${')
  118. # Try to find the color in colors.json
  119. 2 color_key = find_color_key(color, config, source_path)
  120. 2 if color_key
  121. # Return colorResource reference
  122. 2 "colorResource(R.color.#{color_key})"
  123. else
  124. # Return Color.parseColor
  125. "Color(android.graphics.Color.parseColor(#{quote(color)}))"
  126. end
  127. end
  128. 1 private
  129. 1 def cached_strings_data
  130. 1 source_directory = cached_config['source_directory'] || 'src/main'
  131. 1 layouts_dir = File.join(cached_source_path, source_directory, cached_config['layouts_directory'] || 'assets/Layouts')
  132. 1 strings_file = File.join(layouts_dir, 'Resources', 'strings.json')
  133. 1 return {} unless File.exist?(strings_file)
  134. begin
  135. 1 JSON.parse(File.read(strings_file))
  136. rescue JSON::ParserError
  137. {}
  138. end
  139. end
  140. 1 def cached_colors_data
  141. 2 source_directory = cached_config['source_directory'] || 'src/main'
  142. 2 layouts_dir = File.join(cached_source_path, source_directory, cached_config['layouts_directory'] || 'assets/Layouts')
  143. 2 colors_file = File.join(layouts_dir, 'Resources', 'colors.json')
  144. 2 return {} unless File.exist?(colors_file)
  145. begin
  146. 2 JSON.parse(File.read(colors_file))
  147. rescue JSON::ParserError
  148. {}
  149. end
  150. end
  151. 1 def find_string_key(text, config, source_path)
  152. 1 strings_data = cached_strings_data
  153. # First, check if the text itself is a resource key (snake_case like "login_password")
  154. # This handles the case where JSON has text: "login_password" which should resolve to R.string.login_password
  155. 1 if text.match?(/^[a-z]+(_[a-z0-9]+)+$/)
  156. strings_data.each do |file_prefix, file_strings|
  157. next unless file_strings.is_a?(Hash)
  158. file_strings.each do |key, _value|
  159. full_key = "#{file_prefix}_#{key}"
  160. if full_key == text
  161. # Text matches an existing resource key directly
  162. return text
  163. end
  164. end
  165. end
  166. end
  167. # Search through all file prefixes for matching values
  168. 1 strings_data.each do |file_prefix, file_strings|
  169. 1 next unless file_strings.is_a?(Hash)
  170. 1 file_strings.each do |key, value|
  171. 1 if value == text
  172. # Return the full key with prefix
  173. 1 return "#{file_prefix}_#{key}"
  174. end
  175. end
  176. end
  177. nil
  178. end
  179. 1 def find_color_key(color, config, source_path)
  180. 2 colors_data = cached_colors_data
  181. # First check if the color itself is a key in colors.json
  182. 2 if colors_data.has_key?(color)
  183. 1 return color
  184. end
  185. # If it's a hex color, normalize and search by value
  186. 1 if color.match?(/^#?[A-Fa-f0-9]{6,8}$/)
  187. 1 normalized_color = normalize_color(color)
  188. # Search through colors by value
  189. 1 colors_data.each do |key, value|
  190. 1 if normalize_color(value) == normalized_color
  191. 1 return key
  192. end
  193. end
  194. end
  195. # Also check colors.xml for predefined Android colors
  196. # These are colors that might be defined in colors.xml but not in colors.json
  197. colors_xml_path = File.join(source_path, config['source_directory'] || 'src/main', 'res/values/colors.xml')
  198. if File.exist?(colors_xml_path)
  199. # Quick check - if the color name exists in colors.xml
  200. # we'll assume it's available (proper check would parse XML)
  201. xml_content = File.read(colors_xml_path)
  202. if xml_content.include?("name='#{color}'") || xml_content.include?("name=\"#{color}\"")
  203. return color
  204. end
  205. end
  206. nil
  207. end
  208. 1 def normalize_color(color)
  209. 2 return nil unless color.is_a?(String)
  210. # Remove # if present and convert to lowercase
  211. 2 color.sub(/^#/, '').downcase
  212. end
  213. 1 def quote(text)
  214. # Escape special characters properly
  215. 235 escaped = text.to_s.gsub('\\', '\\\\\\\\')
  216. .gsub('"', '\\"')
  217. .gsub("\n", '\\n')
  218. .gsub("\r", '\\r')
  219. .gsub("\t", '\\t')
  220. 235 "\"#{escaped}\""
  221. end
  222. end
  223. end
  224. end
  225. end
  226. end

lib/compose/helpers/visibility_helper.rb

100.0% lines covered

31 relevant lines. 31 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module KjuiTools
  3. 1 module Compose
  4. 1 module Helpers
  5. 1 class VisibilityHelper
  6. 1 def self.wrap_with_visibility(json_data, component_code, depth, required_imports)
  7. 67 visibility_result = ModifierBuilder.build_visibility(json_data, required_imports)
  8. 67 visibility_info = visibility_result[:visibility_info]
  9. # If no visibility attributes, return the component as-is
  10. 67 return component_code if visibility_info.empty?
  11. # Build VisibilityWrapper
  12. 5 wrapper_code = indent("VisibilityWrapper(", depth)
  13. # Add visibility parameters
  14. 5 if visibility_info[:visibility_binding]
  15. 1 wrapper_code += "\n" + indent("visibility = #{visibility_info[:visibility_binding]},", depth + 1)
  16. 4 elsif visibility_info[:visibility]
  17. 2 wrapper_code += "\n" + indent("visibility = \"#{visibility_info[:visibility]}\",", depth + 1)
  18. end
  19. 5 if visibility_info[:hidden_binding]
  20. 1 wrapper_code += "\n" + indent("hidden = #{visibility_info[:hidden_binding]},", depth + 1)
  21. 4 elsif visibility_info[:hidden]
  22. 1 wrapper_code += "\n" + indent("hidden = true,", depth + 1)
  23. end
  24. 5 wrapper_code += "\n" + indent(") {", depth)
  25. 5 wrapper_code += "\n" + component_code
  26. 5 wrapper_code += "\n" + indent("}", depth)
  27. 5 wrapper_code
  28. end
  29. 1 def self.should_skip_render?(json_data)
  30. # Check if component should not be rendered at all (static gone/hidden)
  31. 67 return true if json_data['visibility'] == 'gone' && !json_data['visibility'].to_s.include?('@{')
  32. 66 return true if json_data['hidden'] == true && !json_data['hidden'].to_s.include?('@{')
  33. 65 false
  34. end
  35. 1 private
  36. 1 def self.indent(text, level)
  37. 20 return text if level == 0
  38. 5 spaces = ' ' * level
  39. 5 text.split("\n").map { |line|
  40. 5 line.empty? ? line : spaces + line
  41. }.join("\n")
  42. end
  43. end
  44. end
  45. end
  46. end

lib/compose/setup/compose_setup.rb

45.3% lines covered

117 relevant lines. 53 lines covered and 64 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'fileutils'
  3. 1 require 'json'
  4. 1 require_relative '../../core/config_manager'
  5. 1 require_relative '../../core/project_finder'
  6. 1 module KjuiTools
  7. 1 module Compose
  8. 1 module Setup
  9. 1 class ComposeSetup
  10. 1 def initialize(project_file_path = nil)
  11. 11 @project_file_path = project_file_path
  12. 11 @config = Core::ConfigManager.load_config
  13. 11 @source_path = Core::ProjectFinder.get_full_source_path
  14. 11 @package_name = Core::ProjectFinder.package_name
  15. end
  16. 1 def run_full_setup
  17. 2 puts "Setting up Compose project..."
  18. # Create directory structure
  19. 2 create_directory_structure
  20. # Copy base files
  21. 2 copy_base_files
  22. # Create hotloader config
  23. 2 create_hotloader_config
  24. # Setup network security for hot reload
  25. 2 setup_network_security
  26. # Update build.gradle
  27. 2 update_build_gradle
  28. 2 puts "Compose setup complete!"
  29. end
  30. 1 private
  31. 1 def create_directory_structure
  32. 1 puts "Creating directory structure..."
  33. # Get source directory from config
  34. 1 source_dir = @config['source_directory'] || 'src/main'
  35. directories = [
  36. 1 File.join(source_dir, 'assets/Layouts'),
  37. File.join(source_dir, 'assets/Styles'),
  38. package_path('ui/theme')
  39. # data, viewmodels, views directories will be created by g view command
  40. ]
  41. 1 directories.each do |dir|
  42. # All paths should be relative to the project root
  43. 3 FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
  44. 3 puts " Created: #{dir}"
  45. end
  46. end
  47. 1 def copy_base_files
  48. puts "Creating base files..."
  49. # Create theme file
  50. create_theme_file
  51. # Create MainActivity with Compose setup
  52. create_main_activity
  53. end
  54. 1 def create_theme_file
  55. theme_path = File.join(package_path('ui/theme'), 'Theme.kt')
  56. content = <<~KOTLIN
  57. package #{@package_name}.ui.theme
  58. import androidx.compose.foundation.isSystemInDarkTheme
  59. import androidx.compose.material3.*
  60. import androidx.compose.runtime.Composable
  61. import androidx.compose.ui.graphics.Color
  62. private val LightColorScheme = lightColorScheme(
  63. primary = Color(0xFF6200EE),
  64. onPrimary = Color.White,
  65. secondary = Color(0xFF03DAC6),
  66. onSecondary = Color.Black,
  67. background = Color(0xFFF5F5F5),
  68. onBackground = Color.Black,
  69. surface = Color.White,
  70. onSurface = Color.Black,
  71. )
  72. private val DarkColorScheme = darkColorScheme(
  73. primary = Color(0xFFBB86FC),
  74. onPrimary = Color.Black,
  75. secondary = Color(0xFF03DAC6),
  76. onSecondary = Color.Black,
  77. background = Color(0xFF121212),
  78. onBackground = Color.White,
  79. surface = Color(0xFF121212),
  80. onSurface = Color.White,
  81. )
  82. @Composable
  83. fun KotlinJsonUITheme(
  84. darkTheme: Boolean = isSystemInDarkTheme(),
  85. content: @Composable () -> Unit
  86. ) {
  87. val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
  88. MaterialTheme(
  89. colorScheme = colorScheme,
  90. typography = Typography(),
  91. content = content
  92. )
  93. }
  94. KOTLIN
  95. File.write(theme_path, content)
  96. puts " Created: Theme.kt"
  97. end
  98. 1 def create_main_activity
  99. source_dir = @config['source_directory'] || 'src/main'
  100. package_dirs = @package_name.gsub('.', '/')
  101. activity_path = File.join(source_dir, "kotlin/#{package_dirs}", 'MainActivity.kt')
  102. content = <<~KOTLIN
  103. package #{@package_name}
  104. import android.os.Bundle
  105. import androidx.activity.ComponentActivity
  106. import androidx.activity.compose.setContent
  107. import androidx.compose.foundation.layout.fillMaxSize
  108. import androidx.compose.material3.*
  109. import androidx.compose.runtime.*
  110. import androidx.compose.ui.Modifier
  111. import com.kotlinjsonui.core.DynamicModeManager
  112. import #{@package_name}.ui.theme.KotlinJsonUITheme
  113. import #{@package_name}.views.splash.SplashView
  114. class MainActivity : ComponentActivity() {
  115. override fun onCreate(savedInstanceState: Bundle?) {
  116. super.onCreate(savedInstanceState)
  117. setContent {
  118. val isDynamicModeEnabled by DynamicModeManager.isDynamicModeEnabled.collectAsState()
  119. KotlinJsonUITheme {
  120. Surface(
  121. modifier = Modifier.fillMaxSize(),
  122. color = MaterialTheme.colorScheme.background
  123. ) {
  124. key(isDynamicModeEnabled) {
  125. SplashView()
  126. }
  127. }
  128. }
  129. }
  130. }
  131. }
  132. KOTLIN
  133. File.write(activity_path, content) unless File.exist?(activity_path)
  134. puts " Created: MainActivity.kt" unless File.exist?(activity_path)
  135. end
  136. 1 def update_build_gradle
  137. 1 puts "Updating build.gradle..."
  138. 1 gradle_file = find_app_gradle_file
  139. 1 return unless gradle_file
  140. content = File.read(gradle_file)
  141. # Check if Compose is already configured
  142. unless content.include?('compose')
  143. puts " Adding Compose dependencies to build.gradle..."
  144. # Add compose to buildFeatures
  145. unless content.include?('buildFeatures')
  146. content.gsub!(/android\s*\{/, "android {\n buildFeatures {\n compose = true\n }")
  147. end
  148. # Add compose options
  149. unless content.include?('composeOptions')
  150. content.gsub!(/android\s*\{/, "android {\n composeOptions {\n kotlinCompilerExtensionVersion = \"1.5.7\"\n }")
  151. end
  152. # Add Compose BOM
  153. unless content.include?('androidx.compose:compose-bom')
  154. dependencies_section = content.match(/dependencies\s*\{(.*?)\}/m)
  155. if dependencies_section
  156. new_deps = <<~GRADLE
  157. implementation(platform("androidx.compose:compose-bom:2023.10.01"))
  158. implementation("androidx.compose.ui:ui")
  159. implementation("androidx.compose.ui:ui-tooling-preview")
  160. implementation("androidx.compose.material3:material3")
  161. implementation("androidx.compose.runtime:runtime")
  162. implementation("androidx.activity:activity-compose:1.8.0")
  163. GRADLE
  164. content.gsub!(/dependencies\s*\{/, "dependencies {\n#{new_deps}")
  165. end
  166. end
  167. File.write(gradle_file, content)
  168. puts " Updated build.gradle with Compose dependencies"
  169. else
  170. puts " Compose already configured in build.gradle"
  171. end
  172. end
  173. 1 def create_hotloader_config
  174. puts "Creating hotloader configuration..."
  175. # Determine the correct project directory
  176. project_root = Core::ProjectFinder.project_dir || Dir.pwd
  177. # Check if we're in sample-app
  178. if File.exist?(File.join(project_root, 'sample-app'))
  179. assets_dir = File.join(project_root, 'sample-app', 'src', 'main', 'assets')
  180. else
  181. source_dir = @config['source_directory'] || 'src/main'
  182. assets_dir = File.join(project_root, source_dir, 'assets')
  183. end
  184. FileUtils.mkdir_p(assets_dir)
  185. # Get IP from config or detect it
  186. ip = if @config['hotloader'] && @config['hotloader']['ip']
  187. @config['hotloader']['ip']
  188. else
  189. get_local_ip || '10.0.2.2' # Default to Android emulator IP
  190. end
  191. port = if @config['hotloader'] && @config['hotloader']['port']
  192. @config['hotloader']['port']
  193. else
  194. 8081
  195. end
  196. # Create hotloader.json
  197. hotloader_config_path = File.join(assets_dir, 'hotloader.json')
  198. hotloader_config = {
  199. 'ip' => ip,
  200. 'port' => port,
  201. 'enabled' => false, # Default to disabled for initial setup
  202. 'websocket_endpoint' => "ws://#{ip}:#{port}",
  203. 'http_endpoint' => "http://#{ip}:#{port}"
  204. }
  205. File.write(hotloader_config_path, JSON.pretty_generate(hotloader_config))
  206. puts " Created: hotloader.json (IP: #{ip}:#{port})"
  207. end
  208. 1 def setup_network_security
  209. puts "Setting up network security for hot reload..."
  210. # Determine the correct project directory
  211. project_root = Core::ProjectFinder.project_dir || Dir.pwd
  212. # Check if we're in sample-app
  213. if File.exist?(File.join(project_root, 'sample-app'))
  214. res_dir = File.join(project_root, 'sample-app', 'src', 'main', 'res', 'xml')
  215. debug_dir = File.join(project_root, 'sample-app', 'src', 'debug')
  216. else
  217. source_dir = @config['source_directory'] || 'src/main'
  218. res_dir = File.join(project_root, source_dir, 'res', 'xml')
  219. # Derive debug path from source_dir (e.g., 'app/src/main' -> 'app/src/debug')
  220. debug_dir = File.join(project_root, source_dir.sub('/main', '/debug'))
  221. end
  222. # Create network security config
  223. FileUtils.mkdir_p(res_dir)
  224. network_config_path = File.join(res_dir, 'network_security_config.xml')
  225. network_config = <<~XML
  226. <?xml version="1.0" encoding="utf-8"?>
  227. <network-security-config>
  228. <!-- Allow cleartext traffic for hot reload development server -->
  229. <domain-config cleartextTrafficPermitted="true">
  230. <!-- Android emulator localhost -->
  231. <domain includeSubdomains="true">10.0.2.2</domain>
  232. <!-- Common local network ranges -->
  233. <domain includeSubdomains="true">localhost</domain>
  234. <domain includeSubdomains="true">127.0.0.1</domain>
  235. <!-- Local network IPs (adjust as needed) -->
  236. <domain includeSubdomains="true">192.168.0.0/16</domain>
  237. <domain includeSubdomains="true">192.168.1.0/24</domain>
  238. <domain includeSubdomains="true">192.168.3.0/24</domain>
  239. <domain includeSubdomains="true">10.0.0.0/8</domain>
  240. </domain-config>
  241. <!-- Default configuration for production -->
  242. <base-config cleartextTrafficPermitted="false">
  243. <trust-anchors>
  244. <certificates src="system" />
  245. </trust-anchors>
  246. </base-config>
  247. </network-security-config>
  248. XML
  249. File.write(network_config_path, network_config)
  250. puts " Created: network_security_config.xml"
  251. # Create debug-specific AndroidManifest.xml with both network config and cleartext traffic
  252. FileUtils.mkdir_p(debug_dir)
  253. debug_manifest_path = File.join(debug_dir, 'AndroidManifest.xml')
  254. debug_manifest = <<~XML
  255. <?xml version="1.0" encoding="utf-8"?>
  256. <manifest xmlns:android="http://schemas.android.com/apk/res/android"
  257. xmlns:tools="http://schemas.android.com/tools">
  258. <!-- Internet permission for hot reload -->
  259. <uses-permission android:name="android.permission.INTERNET" />
  260. <!-- Debug-only configuration for hot reload -->
  261. <application
  262. android:networkSecurityConfig="@xml/network_security_config"
  263. android:usesCleartextTraffic="true"
  264. tools:targetApi="31">
  265. </application>
  266. </manifest>
  267. XML
  268. File.write(debug_manifest_path, debug_manifest)
  269. puts " Created: debug/AndroidManifest.xml with cleartext traffic enabled for debug builds only"
  270. end
  271. 1 def get_local_ip
  272. # Try to get WiFi IP first (common interface names)
  273. 1 require 'socket'
  274. 1 Socket.ip_address_list.each do |addr|
  275. 7 if addr.ipv4? && !addr.ipv4_loopback? && !addr.ipv4_multicast?
  276. 1 return addr.ip_address
  277. end
  278. end
  279. nil
  280. rescue
  281. nil
  282. end
  283. 1 def package_path(subpath)
  284. 2 source_dir = @config['source_directory'] || 'src/main'
  285. 2 package_dirs = @package_name.gsub('.', '/')
  286. 2 File.join(source_dir, "kotlin/#{package_dirs}/#{subpath}")
  287. end
  288. 1 def find_app_gradle_file
  289. # Look for app/build.gradle or app/build.gradle.kts
  290. 4 candidates = [
  291. 'app/build.gradle.kts',
  292. 'app/build.gradle',
  293. 'build.gradle.kts',
  294. 'build.gradle'
  295. ]
  296. 4 project_root = Core::ProjectFinder.project_dir || Dir.pwd
  297. 4 candidates.each do |candidate|
  298. 11 path = File.join(project_root, candidate)
  299. 11 return path if File.exist?(path)
  300. end
  301. nil
  302. end
  303. end
  304. end
  305. end
  306. end

lib/compose/style_loader.rb

100.0% lines covered

39 relevant lines. 39 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'json'
  3. 1 require_relative '../core/config_manager'
  4. 1 require_relative '../core/project_finder'
  5. 1 module KjuiTools
  6. 1 module Compose
  7. 1 class StyleLoader
  8. 1 class << self
  9. 1 def load_and_merge(json_data)
  10. 41 return json_data unless json_data.is_a?(Hash)
  11. # Load style if specified
  12. 40 if json_data['style']
  13. 4 style_data = load_style(json_data['style'])
  14. 4 if style_data
  15. # Merge style data with component data
  16. # Component data takes precedence over style data
  17. 3 merged_data = style_data.merge(json_data)
  18. # Remove the style key from the merged data
  19. 3 merged_data.delete('style')
  20. 3 json_data = merged_data
  21. end
  22. end
  23. # Process children recursively
  24. 40 if json_data['child']
  25. 13 if json_data['child'].is_a?(Array)
  26. 23 json_data['child'] = json_data['child'].map { |child| load_and_merge(child) }
  27. else
  28. 2 json_data['child'] = load_and_merge(json_data['child'])
  29. end
  30. end
  31. # Process includes
  32. 40 if json_data['include']
  33. 2 json_data = process_include(json_data)
  34. end
  35. 40 json_data
  36. end
  37. 1 private
  38. 1 def load_style(style_name)
  39. 4 config = Core::ConfigManager.load_config
  40. 4 project_path = Core::ProjectFinder.get_full_source_path || Dir.pwd
  41. 4 source_dir = config['source_directory'] || 'src/main'
  42. 4 source_path = File.join(project_path, source_dir)
  43. 4 styles_dir = File.join(source_path, config['styles_directory'] || 'assets/Styles')
  44. 4 style_file = File.join(styles_dir, "#{style_name}.json")
  45. 4 return nil unless File.exist?(style_file)
  46. begin
  47. 4 style_content = File.read(style_file)
  48. 4 style_data = JSON.parse(style_content)
  49. # Recursively load and merge styles in the style file
  50. 3 load_and_merge(style_data)
  51. 1 rescue JSON::ParserError => e
  52. 1 puts "Warning: Failed to parse style file #{style_file}: #{e.message}"
  53. 1 nil
  54. end
  55. end
  56. 1 def process_include(json_data)
  57. # For Compose generation, don't expand includes inline
  58. # They should be handled as component calls in compose_builder
  59. 2 json_data
  60. end
  61. end
  62. end
  63. end
  64. end

lib/core/attribute_validator.rb

78.67% lines covered

286 relevant lines. 225 lines covered and 61 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 require 'json'
  3. 1 module KjuiTools
  4. 1 module Core
  5. # Validates JSON component attributes against defined schemas
  6. # Used by both XML and Compose converters
  7. 1 class AttributeValidator
  8. 1 attr_reader :definitions, :warnings, :infos
  9. 1 attr_accessor :mode, :styles_dir
  10. # Valid modes for this platform
  11. 1 MODES = [:xml, :compose, :dynamic, :all].freeze
  12. # Current platform identifier
  13. 1 PLATFORM = 'kotlin'.freeze
  14. # All supported platforms across JsonUI libraries
  15. 1 ALL_PLATFORMS = ['swift', 'kotlin', 'react'].freeze
  16. 1 def initialize(mode = :all, styles_dir = nil)
  17. 132 @definitions = load_definitions
  18. 132 @warnings = []
  19. 132 @infos = []
  20. 132 @mode = mode
  21. 132 @styles_dir = styles_dir
  22. 132 @styles_cache = {}
  23. end
  24. # Validate a component and return warnings
  25. # @param component [Hash] The component to validate
  26. # @param component_type [String] The type of component (e.g., "Label", "TextField")
  27. # @param parent_orientation [String] The parent's orientation ('horizontal' or 'vertical')
  28. # @return [Array<String>] Array of warning messages
  29. 1 def validate(component, component_type = nil, parent_orientation = nil)
  30. 107 @warnings = []
  31. 107 @infos = []
  32. # Merge style attributes before validation
  33. 107 merged_component = merge_style_attributes(component)
  34. 107 type = component_type || merged_component['type']
  35. 107 return @warnings unless type
  36. # Get valid attributes for this component type
  37. 106 valid_attrs = get_valid_attributes(type)
  38. # Check each attribute in the merged component
  39. 106 merged_component.each do |key, value|
  40. # Skip internal/structural attributes (including _ prefixed internal flags)
  41. 454 next if key == 'type' || key == 'child' || key == 'children' || key.start_with?('_')
  42. 339 if valid_attrs.key?(key)
  43. 334 attr_def = valid_attrs[key]
  44. # Check platform compatibility first
  45. 334 if platform_compatible?(attr_def)
  46. # Check mode compatibility
  47. 332 if mode_compatible?(attr_def)
  48. # Validate attribute value
  49. 332 validate_attribute(key, value, attr_def, type)
  50. else
  51. # Attribute not supported in current mode - log as info
  52. add_mode_info(key, attr_def, type)
  53. end
  54. else
  55. # Attribute for other platform - log as info
  56. 2 add_platform_info(key, attr_def, type)
  57. end
  58. else
  59. # Unknown attribute
  60. 5 add_warning("Unknown attribute '#{key}' for component type '#{type}'")
  61. end
  62. end
  63. # Check for required attributes (only for current platform)
  64. 106 valid_attrs.each do |attr_name, attr_def|
  65. 15816 next unless platform_compatible?(attr_def)
  66. 10049 if attr_def['required'] && !merged_component.key?(attr_name)
  67. # Skip width/height required check if weight is set and parent orientation allows it
  68. 51 next if skip_dimension_required?(attr_name, merged_component, parent_orientation)
  69. 48 add_warning("Required attribute '#{attr_name}' is missing for component type '#{type}'")
  70. end
  71. end
  72. # Check for conflicting attributes
  73. 106 check_spacing_gravity_conflict(merged_component, type)
  74. # Check for weight + dimension conflict
  75. 106 check_weight_dimension_conflict(merged_component, type, parent_orientation)
  76. 106 @warnings
  77. end
  78. # Print all warnings to console
  79. 1 def print_warnings
  80. @warnings.each do |warning|
  81. puts "\e[33m⚠️ [KJUI Warning] #{warning}\e[0m"
  82. end
  83. end
  84. # Print all info messages to console
  85. 1 def print_infos
  86. @infos.each do |info|
  87. puts "\e[36mℹ️ [KJUI Info] #{info}\e[0m"
  88. end
  89. end
  90. # Check if there are any warnings
  91. 1 def has_warnings?
  92. 2 !@warnings.empty?
  93. end
  94. # Check if there are any info messages
  95. 1 def has_infos?
  96. !@infos.empty?
  97. end
  98. 1 private
  99. 1 def load_definitions
  100. 132 definitions_path = File.join(File.dirname(__FILE__), 'attribute_definitions.json')
  101. 132 base_definitions = if File.exist?(definitions_path)
  102. 132 JSON.parse(File.read(definitions_path))
  103. else
  104. puts "\e[31m[KJUI Error] attribute_definitions.json not found at #{definitions_path}\e[0m"
  105. {}
  106. end
  107. # Load and merge extension attribute definitions
  108. 132 extension_definitions = load_extension_definitions
  109. 132 merge_definitions(base_definitions, extension_definitions)
  110. end
  111. # Load extension attribute definitions from the extensions directory
  112. 1 def load_extension_definitions
  113. 132 extension_defs = {}
  114. # Check for extension definitions in various locations
  115. extension_paths = [
  116. # Main KotlinJsonUI structure
  117. 132 File.join(Dir.pwd, 'kjui_tools', 'lib', 'compose', 'components', 'extensions', 'attribute_definitions'),
  118. # Test app structure
  119. File.join(Dir.pwd, 'app', 'kjui_tools', 'lib', 'compose', 'components', 'extensions', 'attribute_definitions')
  120. ]
  121. 132 extension_paths.each do |ext_dir|
  122. 264 next unless File.directory?(ext_dir)
  123. 124 Dir.glob(File.join(ext_dir, '*.json')).each do |file|
  124. begin
  125. 5 component_defs = JSON.parse(File.read(file))
  126. 5 extension_defs.merge!(component_defs)
  127. rescue JSON::ParserError => e
  128. puts "\e[33m[KJUI Warning] Failed to parse extension definition #{file}: #{e.message}\e[0m"
  129. end
  130. end
  131. end
  132. 132 extension_defs
  133. end
  134. # Merge extension definitions into base definitions
  135. 1 def merge_definitions(base, extensions)
  136. 132 extensions.each do |key, value|
  137. 5 if base.key?(key) && base[key].is_a?(Hash) && value.is_a?(Hash)
  138. # Merge attributes for existing component types
  139. base[key] = base[key].merge(value)
  140. else
  141. # Add new component type definitions
  142. 5 base[key] = value
  143. end
  144. end
  145. 132 base
  146. end
  147. # Get valid attributes for a component type (common + type-specific)
  148. 1 def get_valid_attributes(type)
  149. 106 attrs = {}
  150. # Add common attributes
  151. 106 attrs.merge!(@definitions['common'] || {})
  152. # Map component type to definition key
  153. 106 def_key = map_type_to_definition(type)
  154. # Add type-specific attributes
  155. 106 if @definitions[def_key]
  156. 106 attrs.merge!(@definitions[def_key])
  157. end
  158. 106 attrs
  159. end
  160. # Map JSON type to definition key
  161. 1 def map_type_to_definition(type)
  162. 106 case type
  163. when 'Label', 'Text'
  164. 19 'Label'
  165. when 'TextField', 'EditText'
  166. 11 'TextField'
  167. when 'TextView', 'MultiLineEditText'
  168. 'TextView'
  169. when 'Button'
  170. 1 'Button'
  171. when 'Image', 'ImageView'
  172. 1 'Image'
  173. when 'NetworkImage', 'NetworkImageView'
  174. 'NetworkImage'
  175. when 'CircleImage', 'CircleImageView'
  176. 'CircleImage'
  177. when 'SelectBox', 'Spinner', 'DatePicker'
  178. 1 'SelectBox'
  179. when 'Toggle', 'Switch'
  180. 7 'Toggle'
  181. when 'CheckBox', 'Check'
  182. 5 type == 'CheckBox' ? 'CheckBox' : 'Check'
  183. when 'Radio', 'RadioButton', 'RadioGroup'
  184. 1 'Radio'
  185. when 'Segment', 'SegmentedControl', 'TabLayout'
  186. 'Segment'
  187. when 'Slider', 'SeekBar'
  188. 'Slider'
  189. when 'Progress', 'ProgressBar'
  190. 'Progress'
  191. when 'Indicator', 'ActivityIndicator'
  192. 'Indicator'
  193. when 'View', 'Container', 'SafeAreaView', 'LinearLayout', 'RelativeLayout', 'FrameLayout',
  194. 'VStack', 'HStack', 'ZStack', 'Column', 'Row', 'Box'
  195. 53 'View'
  196. when 'ScrollView', 'Scroll'
  197. 1 'ScrollView'
  198. when 'Collection', 'CollectionView', 'RecyclerView', 'LazyGrid', 'Grid'
  199. 2 'Collection'
  200. when 'Table', 'TableView', 'ListView', 'LazyColumn'
  201. 'Table'
  202. when 'GradientView'
  203. 'GradientView'
  204. when 'Blur', 'BlurView'
  205. 'Blur'
  206. when 'IconLabel'
  207. 'IconLabel'
  208. when 'Web', 'WebView'
  209. 'Web'
  210. when 'TabView'
  211. 'TabView'
  212. when 'ConstraintLayout'
  213. 'View'
  214. else
  215. 4 type
  216. end
  217. end
  218. # Validate a single attribute value
  219. 1 def validate_attribute(name, value, definition, component_type, path = nil)
  220. 337 return unless definition
  221. 337 current_path = path ? "#{path}.#{name}" : name
  222. # Check for invalid binding syntax
  223. 337 check_invalid_binding_syntax(value, current_path, component_type)
  224. # Check if value is a binding expression
  225. 337 is_binding = value.is_a?(String) && value.start_with?('@{') && value.end_with?('}')
  226. # Skip validation for binding expressions
  227. 337 return if is_binding
  228. # Check type
  229. 306 expected_types = Array(definition['type'])
  230. 306 actual_type = get_value_type(value)
  231. 306 unless type_matches?(actual_type, expected_types, value, definition)
  232. 3 add_warning("Attribute '#{current_path}' in '#{component_type}' expects #{format_expected_types(expected_types)}, got #{actual_type}")
  233. 3 return # Don't validate nested properties if type is wrong
  234. end
  235. # Check enum values
  236. 303 if definition['enum']
  237. 24 validate_enum_value(value, definition['enum'], current_path, component_type)
  238. end
  239. # Check min/max for numbers
  240. 303 if actual_type == 'number'
  241. 54 if definition['min'] && value < definition['min']
  242. 1 add_warning("Attribute '#{current_path}' in '#{component_type}' value #{value} is less than minimum #{definition['min']}")
  243. end
  244. 54 if definition['max'] && value > definition['max']
  245. 1 add_warning("Attribute '#{current_path}' in '#{component_type}' value #{value} is greater than maximum #{definition['max']}")
  246. end
  247. end
  248. # Validate nested object properties
  249. 303 if actual_type == 'object' && definition['properties']
  250. 2 validate_nested_object(value, definition['properties'], component_type, current_path)
  251. end
  252. # Validate array items
  253. 303 if actual_type == 'array' && definition['items']
  254. validate_array_items(value, definition['items'], component_type, current_path)
  255. end
  256. end
  257. # Validate enum value (supports both single values and arrays)
  258. 1 def validate_enum_value(value, enum_values, path, component_type)
  259. 24 if value.is_a?(Array)
  260. # For array values, check each element
  261. 11 invalid_values = value.reject { |v| enum_values.include?(v) }
  262. 4 unless invalid_values.empty?
  263. 2 add_warning("Attribute '#{path}' in '#{component_type}' has invalid value(s) '#{invalid_values.inspect}'. Valid values: #{enum_values.join(', ')}")
  264. end
  265. else
  266. # For single values
  267. 20 unless enum_values.include?(value)
  268. 4 add_warning("Attribute '#{path}' in '#{component_type}' has invalid value '#{value}'. Valid values: #{enum_values.join(', ')}")
  269. end
  270. end
  271. end
  272. # Format expected types for error messages
  273. 1 def format_expected_types(expected_types)
  274. 3 formatted = expected_types.map do |type|
  275. 6 if type.is_a?(Hash) && type['enum']
  276. 1 "enum(#{type['enum'].join(', ')})"
  277. else
  278. 5 type
  279. end
  280. end
  281. 3 formatted.join(' or ')
  282. end
  283. # Validate nested object properties
  284. 1 def validate_nested_object(obj, properties, component_type, path)
  285. 2 return unless obj.is_a?(Hash)
  286. 2 obj.each do |key, value|
  287. 5 if properties.key?(key)
  288. 5 validate_attribute(key, value, properties[key], component_type, path)
  289. else
  290. add_warning("Unknown property '#{path}.#{key}' in '#{component_type}'")
  291. end
  292. end
  293. end
  294. # Validate array items
  295. 1 def validate_array_items(arr, item_def, component_type, path)
  296. return unless arr.is_a?(Array)
  297. arr.each_with_index do |item, index|
  298. item_path = "#{path}[#{index}]"
  299. if item_def['type'] == 'object' && item_def['properties']
  300. if item.is_a?(Hash)
  301. validate_nested_object(item, item_def['properties'], component_type, item_path)
  302. else
  303. add_warning("#{item_path} in '#{component_type}' expects object, got #{get_value_type(item)}")
  304. end
  305. else
  306. # Simple type validation for array items
  307. expected_types = Array(item_def['type'])
  308. actual_type = get_value_type(item)
  309. unless type_matches?(actual_type, expected_types, item, item_def)
  310. add_warning("#{item_path} in '#{component_type}' expects #{expected_types.join(' or ')}, got #{actual_type}")
  311. end
  312. end
  313. end
  314. end
  315. 1 def get_value_type(value)
  316. 306 case value
  317. when String
  318. 231 'string'
  319. when Integer, Float
  320. 54 'number'
  321. when TrueClass, FalseClass
  322. 12 'boolean'
  323. when Array
  324. 7 'array'
  325. when Hash
  326. 2 'object'
  327. when NilClass
  328. 'null'
  329. else
  330. 'unknown'
  331. end
  332. end
  333. 1 def type_matches?(actual, expected_types, value, definition = nil)
  334. 306 expected_types.any? do |expected|
  335. 460 case expected
  336. when 'string'
  337. 91 actual == 'string'
  338. when 'number'
  339. 202 actual == 'number'
  340. when 'boolean'
  341. 12 actual == 'boolean'
  342. when 'array'
  343. 7 actual == 'array'
  344. when 'object'
  345. 2 actual == 'object'
  346. when 'binding'
  347. # binding type requires @{propertyName} format
  348. 2 actual == 'string' && value.is_a?(String) && value.start_with?('@{') && value.end_with?('}')
  349. when 'any'
  350. true
  351. when Hash
  352. # Handle enum type definition: {"enum": [...]}
  353. 144 if expected['enum']
  354. 144 if actual == 'string'
  355. 144 expected['enum'].include?(value)
  356. elsif actual == 'array'
  357. # For array values, check if all elements are in enum
  358. value.is_a?(Array) && value.all? { |v| expected['enum'].include?(v) }
  359. else
  360. false
  361. end
  362. else
  363. false
  364. end
  365. else
  366. # For union types or special cases
  367. actual == expected
  368. end
  369. end
  370. end
  371. 1 def add_warning(message)
  372. 74 @warnings << message unless @warnings.include?(message)
  373. end
  374. 1 def add_info(message)
  375. 2 @infos << message unless @infos.include?(message)
  376. end
  377. # Check for invalid binding syntax (starts with @{ but doesn't end with })
  378. 1 def check_invalid_binding_syntax(value, path, component_type)
  379. 337 return unless value.is_a?(String)
  380. 262 return unless value.start_with?('@{')
  381. 35 return if value.end_with?('}')
  382. 4 add_warning("Attribute '#{path}' in '#{component_type}' has invalid binding syntax (starts with '@{' but doesn't end with '}')")
  383. end
  384. # Check for conflicting spacing and gravity attributes
  385. # Using both spacing and gravity together can cause unexpected layout behavior
  386. 1 def check_spacing_gravity_conflict(component, component_type)
  387. 106 has_spacing = component.key?('spacing') || component.key?('distribution')
  388. 106 has_gravity = component.key?('gravity')
  389. 106 if has_spacing && has_gravity
  390. add_warning("Component '#{component_type}' has both 'spacing'/'distribution' and 'gravity' set. This combination may cause unexpected layout behavior. Consider using only one of these attributes.")
  391. end
  392. end
  393. # Check for weight + dimension conflict in the same direction as parent orientation
  394. # - parent orientation: horizontal + width + weight -> warning
  395. # - parent orientation: vertical + height + weight -> warning
  396. # - no orientation (ZStack) + weight -> warning (weight is invalid)
  397. 1 def check_weight_dimension_conflict(component, component_type, parent_orientation)
  398. 106 return unless component.key?('weight')
  399. 9 case parent_orientation
  400. when 'horizontal'
  401. 5 if component.key?('width')
  402. 3 add_warning("Component '#{component_type}' has both 'weight' and 'width' in horizontal layout. 'weight' will override 'width'. Consider removing 'width'.")
  403. end
  404. when 'vertical'
  405. 2 if component.key?('height')
  406. 1 add_warning("Component '#{component_type}' has both 'weight' and 'height' in vertical layout. 'weight' will override 'height'. Consider removing 'height'.")
  407. end
  408. else
  409. # No orientation means ZStack - weight is not applicable
  410. 2 add_warning("Component '#{component_type}' has 'weight' but parent has no orientation (ZStack). 'weight' only works in horizontal/vertical layouts. Consider removing 'weight'.")
  411. end
  412. end
  413. # Check if width/height required warning should be skipped
  414. # When weight is set, the dimension in the parent's orientation direction is not required
  415. # - parent orientation: horizontal -> width not required if weight is set
  416. # - parent orientation: vertical -> height not required if weight is set
  417. 1 def skip_dimension_required?(attr_name, component, parent_orientation)
  418. 51 return false unless component.key?('weight')
  419. 3 return false unless %w[width height].include?(attr_name)
  420. 3 case parent_orientation
  421. when 'horizontal'
  422. # In horizontal layout, weight determines width
  423. 2 attr_name == 'width'
  424. when 'vertical'
  425. # In vertical layout, weight determines height
  426. 1 attr_name == 'height'
  427. else
  428. # Default orientation is vertical, so height is determined by weight
  429. attr_name == 'height'
  430. end
  431. end
  432. # Check if attribute is compatible with current platform
  433. # Attributes with platform specified for other platforms are silently skipped
  434. 1 def platform_compatible?(attr_def)
  435. 16150 return true unless attr_def['platform']
  436. 5769 attr_platforms = Array(attr_def['platform'])
  437. 5769 attr_platforms.include?(PLATFORM) || attr_platforms.include?('all')
  438. end
  439. # Check if attribute is compatible with current mode
  440. 1 def mode_compatible?(attr_def)
  441. 332 return true if @mode == :all
  442. 49 return true unless attr_def['mode']
  443. 4 attr_modes = Array(attr_def['mode'])
  444. 4 attr_modes.include?(@mode.to_s) || attr_modes.include?('all')
  445. end
  446. # Add info for mode-incompatible attribute (not an error, just informational)
  447. 1 def add_mode_info(attr_name, attr_def, component_type)
  448. attr_modes = Array(attr_def['mode'])
  449. mode_str = attr_modes.map { |m| m.capitalize }.join('/')
  450. current_mode_str = @mode.to_s.capitalize
  451. add_info("Attribute '#{attr_name}' in '#{component_type}' is for #{mode_str} mode (current: #{current_mode_str})")
  452. end
  453. # Add info for platform-specific attribute (not an error, just informational)
  454. 1 def add_platform_info(attr_name, attr_def, component_type)
  455. 2 attr_platforms = Array(attr_def['platform'])
  456. 4 platform_str = attr_platforms.map { |p| p.capitalize }.join('/')
  457. 2 add_info("Attribute '#{attr_name}' in '#{component_type}' is for #{platform_str} platform (current: #{PLATFORM.capitalize})")
  458. end
  459. # Merge style attributes into component for validation
  460. # Style provides base attributes, component attributes override
  461. # @param component [Hash] The component to process
  462. # @return [Hash] Component with style attributes merged
  463. 1 def merge_style_attributes(component)
  464. 107 return component unless component.is_a?(Hash)
  465. 107 return component unless component['style']
  466. 5 style_name = component['style']
  467. 5 style_data = load_style_file(style_name)
  468. 5 return component unless style_data
  469. # Create merged result: style as base, component overrides
  470. 3 component_without_style = component.dup
  471. 3 component_without_style.delete('style')
  472. # If component has type, ignore style's type
  473. 3 style_data_for_merge = style_data.dup
  474. 3 if component_without_style['type']
  475. 3 style_data_for_merge.delete('type')
  476. end
  477. # Deep merge: style as base, component properties override
  478. 3 deep_merge(style_data_for_merge, component_without_style)
  479. end
  480. # Load style file from styles directory
  481. # @param style_name [String] Name of the style file (without .json extension)
  482. # @return [Hash, nil] Parsed style data or nil if not found
  483. 1 def load_style_file(style_name)
  484. 5 return @styles_cache[style_name] if @styles_cache.key?(style_name)
  485. 5 styles_dir = determine_styles_dir
  486. 5 return nil unless styles_dir
  487. 5 style_file = File.join(styles_dir, "#{style_name}.json")
  488. 5 return nil unless File.exist?(style_file)
  489. begin
  490. 3 style_data = JSON.parse(File.read(style_file))
  491. 3 @styles_cache[style_name] = style_data
  492. 3 style_data
  493. rescue JSON::ParserError
  494. nil
  495. end
  496. end
  497. # Determine the styles directory path
  498. # @return [String, nil] Path to styles directory or nil
  499. 1 def determine_styles_dir
  500. 5 return @styles_dir if @styles_dir && Dir.exist?(@styles_dir)
  501. # Try to read from config first
  502. 1 config = load_kjui_config
  503. 1 if config
  504. source_dir = config['source_directory']
  505. styles_dir = config['styles_directory']
  506. if source_dir && styles_dir
  507. config_path = File.join(Dir.pwd, source_dir, styles_dir)
  508. return config_path if Dir.exist?(config_path)
  509. end
  510. end
  511. # Fallback to common locations for Android projects
  512. possible_dirs = [
  513. # Styles inside Layouts directory (common pattern)
  514. 1 File.join(Dir.pwd, 'src', 'main', 'assets', 'Layouts', 'Styles'),
  515. File.join(Dir.pwd, 'app', 'src', 'main', 'assets', 'Layouts', 'Styles'),
  516. # Styles at assets root
  517. File.join(Dir.pwd, 'src', 'main', 'assets', 'Styles'),
  518. File.join(Dir.pwd, 'app', 'src', 'main', 'assets', 'Styles'),
  519. # Other common locations
  520. File.join(Dir.pwd, 'Styles'),
  521. File.join(Dir.pwd, 'styles'),
  522. File.join(Dir.pwd, 'Layouts', 'Styles'),
  523. File.join(Dir.pwd, 'Layouts', 'styles')
  524. ]
  525. 4 possible_dirs.find { |dir| Dir.exist?(dir) }
  526. end
  527. # Load kjui.config.json if it exists
  528. # @return [Hash, nil] Config hash or nil
  529. 1 def load_kjui_config
  530. 1 config_path = File.join(Dir.pwd, 'kjui.config.json')
  531. 1 return nil unless File.exist?(config_path)
  532. JSON.parse(File.read(config_path))
  533. rescue JSON::ParserError
  534. nil
  535. end
  536. # Deep merge two hashes
  537. # @param hash1 [Hash] Base hash
  538. # @param hash2 [Hash] Override hash
  539. # @return [Hash] Merged hash
  540. 1 def deep_merge(hash1, hash2)
  541. 3 return hash2 if hash1.nil?
  542. 3 return hash1 if hash2.nil?
  543. 3 result = hash1.dup
  544. 3 hash2.each do |key, value|
  545. 4 if result[key].is_a?(Hash) && value.is_a?(Hash)
  546. result[key] = deep_merge(result[key], value)
  547. else
  548. 4 result[key] = value
  549. end
  550. end
  551. 3 result
  552. end
  553. end
  554. end
  555. end

lib/core/binding_validator.rb

78.87% lines covered

142 relevant lines. 112 lines covered and 30 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 require 'json'
  3. 1 require 'set'
  4. 1 module KjuiTools
  5. 1 module Core
  6. # Validates binding expressions in JSON layouts
  7. # Warns when bindings contain business logic that should be in ViewModel
  8. 1 class BindingValidator
  9. 1 attr_reader :warnings
  10. # Patterns that indicate business logic in bindings
  11. BUSINESS_LOGIC_PATTERNS = [
  12. # Ternary operators (Kotlin: if-else expression or ternary-like)
  13. {
  14. 1 pattern: /\?.*:/,
  15. message: "ternary operator (?:) - move condition logic to ViewModel"
  16. },
  17. # Kotlin if expression
  18. {
  19. pattern: /\bif\s*\(/,
  20. message: "if expression - move condition logic to ViewModel"
  21. },
  22. # Kotlin when expression
  23. {
  24. pattern: /\bwhen\s*[({]/,
  25. message: "when expression - move logic to ViewModel"
  26. },
  27. # Comparison operators
  28. {
  29. pattern: /[<>=!]=|[<>]/,
  30. message: "comparison operator - move to ViewModel computed property"
  31. },
  32. # Arithmetic operators (but allow simple negation)
  33. {
  34. pattern: /(?<![a-zA-Z_])[+\/*%]|(?<![a-zA-Z_0-9])-(?![a-zA-Z_0-9}])/,
  35. message: "arithmetic operator - compute value in ViewModel"
  36. },
  37. # Logical operators
  38. {
  39. pattern: /&&|\|\|/,
  40. message: "logical operator (&&, ||) - move logic to ViewModel"
  41. },
  42. # Elvis operator (null coalescing)
  43. {
  44. pattern: /\?:/,
  45. message: "elvis operator (?:) - handle null in ViewModel"
  46. },
  47. # Method calls with arguments (but allow simple property access)
  48. {
  49. pattern: /\.\w+\([^)]+\)/,
  50. message: "method call with arguments - move to ViewModel"
  51. },
  52. # String interpolation
  53. {
  54. pattern: /\$\{|\$[a-zA-Z]/,
  55. message: "string interpolation - compose string in ViewModel"
  56. },
  57. # Array subscript with complex expression
  58. {
  59. pattern: /\[[^\]]*[+\-*\/<>=]/,
  60. message: "complex array subscript - simplify in ViewModel"
  61. },
  62. # Type casting
  63. {
  64. pattern: /\s+as[?\s]+\w+/,
  65. message: "type casting - handle type conversion in ViewModel"
  66. },
  67. # Not-null assertion
  68. {
  69. pattern: /!!/,
  70. message: "not-null assertion (!!) - handle nullability safely in ViewModel"
  71. },
  72. # Lambda expressions
  73. {
  74. pattern: /\{[^}]*->[^}]*\}/,
  75. message: "lambda expression - move to ViewModel"
  76. },
  77. # Range operators
  78. {
  79. pattern: /\.\.|\s+until\s+|\s+downTo\s+/,
  80. message: "range operator - create range in ViewModel"
  81. },
  82. # let/run/apply/also blocks
  83. {
  84. pattern: /\.(let|run|apply|also|with)\s*\{/,
  85. message: "scope function - move logic to ViewModel"
  86. }
  87. ].freeze
  88. # Allowed simple patterns that look like logic but are acceptable
  89. 1 ALLOWED_PATTERNS = [
  90. # Simple property access (including safe call)
  91. /^@\{[a-zA-Z_][a-zA-Z0-9_]*(\??\.[a-zA-Z_][a-zA-Z0-9_]*)*\}$/,
  92. # Simple negation for boolean
  93. /^@\{![a-zA-Z_][a-zA-Z0-9_]*\}$/,
  94. # Simple array access with constant index
  95. /^@\{[a-zA-Z_][a-zA-Z0-9_]*\[\d+\]\}$/,
  96. # Action bindings (callbacks)
  97. /^@\{on[A-Z][a-zA-Z0-9_]*\}$/,
  98. # data. prefix for accessing data properties (e.g., @{data.name} in Collection cells)
  99. /^@\{data\.[a-zA-Z_][a-zA-Z0-9_.]*\}$/
  100. ].freeze
  101. 1 def initialize
  102. 40 @warnings = []
  103. 40 @data_properties = Set.new
  104. 40 @data_types = {} # Store property name -> type mapping
  105. end
  106. # Validate all bindings in a JSON component tree
  107. # @param json_data [Hash] The root component
  108. # @param file_name [String] The file name for error messages
  109. # @return [Array<String>] Array of warning messages
  110. 1 def validate(json_data, file_name = nil)
  111. 37 @warnings = []
  112. 37 @current_file = file_name
  113. 37 @data_properties = Set.new
  114. 37 @data_types = {}
  115. # First pass: collect all data property names and types
  116. 37 collect_data_properties(json_data)
  117. # Second pass: validate bindings
  118. 37 validate_component(json_data)
  119. 37 @warnings
  120. end
  121. # Check if there are any warnings
  122. 1 def has_warnings?
  123. 2 !@warnings.empty?
  124. end
  125. # Print all warnings to stdout
  126. 1 def print_warnings
  127. @warnings.each do |warning|
  128. puts "\e[33m[KJUI Binding Warning]\e[0m #{warning}"
  129. end
  130. end
  131. # Check a single binding expression
  132. # @param binding_expr [String] The binding expression (without @{ })
  133. # @param attribute_name [String] The attribute name
  134. # @param component_type [String] The component type
  135. # @return [Array<String>] Array of warning messages
  136. 1 def check_binding(binding_expr, attribute_name, component_type)
  137. 37 warnings = []
  138. # Check if it's allowed simple pattern
  139. 37 full_binding = "@{#{binding_expr}}"
  140. 37 return warnings if allowed_pattern?(full_binding)
  141. # Check for business logic patterns
  142. 14 BUSINESS_LOGIC_PATTERNS.each do |rule|
  143. 210 if binding_expr.match?(rule[:pattern])
  144. 16 context = @current_file ? "[#{@current_file}] " : ""
  145. 16 warnings << "#{context}Binding '@{#{binding_expr}}' in '#{component_type}.#{attribute_name}' contains #{rule[:message]}"
  146. end
  147. end
  148. 14 warnings
  149. end
  150. 1 private
  151. # Collect all data property names and types from the component tree
  152. 1 def collect_data_properties(component)
  153. 50 return unless component.is_a?(Hash)
  154. # Check for data declarations
  155. 50 if component['data'].is_a?(Array)
  156. 22 component['data'].each do |data_item|
  157. 29 next unless data_item.is_a?(Hash)
  158. # Skip ViewModel class declarations (they have 'class' key but no 'name')
  159. # e.g., { "class": "MyViewModel" } - this is a ViewModel class, not a property
  160. # But include property declarations: { "name": "userName", "class": "String" }
  161. 29 next if data_item['class'] && !data_item['name']
  162. # Add property name and type to the maps
  163. 28 if data_item['name']
  164. 28 @data_properties << data_item['name']
  165. 28 @data_types[data_item['name']] = data_item['class'] if data_item['class']
  166. end
  167. end
  168. end
  169. # Recurse into children
  170. 50 children = component['child'] || component['children'] || []
  171. 50 children = [children] unless children.is_a?(Array)
  172. 60 children.each { |child| collect_data_properties(child) if child.is_a?(Hash) }
  173. # Recurse into sections
  174. 50 if component['sections'].is_a?(Array)
  175. 2 component['sections'].each do |section|
  176. 2 next unless section.is_a?(Hash)
  177. 2 ['header', 'footer', 'cell'].each do |key|
  178. 6 collect_data_properties(section[key]) if section[key].is_a?(Hash)
  179. end
  180. end
  181. end
  182. end
  183. 1 def validate_component(component, parent_type = nil)
  184. 50 return unless component.is_a?(Hash)
  185. 50 component_type = component['type'] || parent_type || 'Unknown'
  186. # Check each attribute for bindings
  187. 50 component.each do |key, value|
  188. 126 next if key == 'type' || key == 'child' || key == 'children' || key == 'sections'
  189. 63 next if key == 'data' || key == 'generatedBy' || key == 'include' || key == 'style'
  190. 39 check_value_for_bindings(value, key, component_type)
  191. end
  192. # Validate children
  193. 50 children = component['child'] || component['children'] || []
  194. 50 children = [children] unless children.is_a?(Array)
  195. 60 children.each { |child| validate_component(child, component_type) if child.is_a?(Hash) }
  196. # Validate sections (Collection/Table)
  197. 50 if component['sections'].is_a?(Array)
  198. 2 component['sections'].each do |section|
  199. 2 next unless section.is_a?(Hash)
  200. 2 ['header', 'footer', 'cell'].each do |key|
  201. 6 validate_component(section[key], component_type) if section[key].is_a?(Hash)
  202. end
  203. end
  204. end
  205. end
  206. 1 def check_value_for_bindings(value, attribute_name, component_type)
  207. # Check visibility attribute for Boolean type (should use String enum: visible, gone, invisible)
  208. # Must be called for all value types including TrueClass/FalseClass
  209. 39 check_visibility_type(value, attribute_name, component_type)
  210. 39 case value
  211. when String
  212. 39 if value.start_with?('@{') && value.end_with?('}')
  213. 35 binding_expr = value[2..-2] # Remove @{ and }
  214. 35 binding_warnings = check_binding(binding_expr, attribute_name, component_type)
  215. 35 @warnings.concat(binding_warnings)
  216. # Check if binding variables are defined in data
  217. 35 check_undefined_variables(binding_expr, attribute_name, component_type)
  218. # Check if color attributes have correct type (should be Color, not String)
  219. 35 check_color_type(binding_expr, attribute_name, component_type)
  220. end
  221. when Hash
  222. value.each do |k, v|
  223. check_value_for_bindings(v, "#{attribute_name}.#{k}", component_type)
  224. end
  225. when Array
  226. value.each_with_index do |item, index|
  227. check_value_for_bindings(item, "#{attribute_name}[#{index}]", component_type)
  228. end
  229. end
  230. end
  231. # Check if variables in binding expression are defined in data
  232. 1 def check_undefined_variables(binding_expr, attribute_name, component_type)
  233. # Skip data. prefix bindings (Collection cell bindings)
  234. 35 return if binding_expr.start_with?('data.')
  235. # Extract variable names from the binding expression
  236. 33 variables = extract_variables(binding_expr)
  237. 33 variables.each do |var|
  238. 36 unless @data_properties.include?(var)
  239. 10 context = @current_file ? "[#{@current_file}] " : ""
  240. 10 @warnings << "#{context}Binding variable '#{var}' in '#{component_type}.#{attribute_name}' is not defined in data. Add: { \"class\": \"#{infer_type(var, attribute_name, component_type)}\", \"name\": \"#{var}\" }"
  241. end
  242. end
  243. end
  244. # Extract variable names from binding expression
  245. 1 def extract_variables(binding_expr)
  246. 33 variables = Set.new
  247. # Remove string literals to avoid false positives
  248. 33 expr = binding_expr.gsub(/'[^']*'/, '').gsub(/"[^"]*"/, '')
  249. # Match variable names (identifiers that are not keywords or literals)
  250. # Skip: numbers, true, false, null, visible, gone
  251. 33 keywords = %w[true false null visible gone]
  252. 33 expr.scan(/\b([a-zA-Z_][a-zA-Z0-9_]*)\b/).flatten.each do |match|
  253. 36 next if keywords.include?(match)
  254. 36 next if match =~ /^\d/ # Skip if starts with digit
  255. 36 variables << match
  256. end
  257. 33 variables.to_a
  258. end
  259. # Infer type from variable name and attribute context
  260. # Returns Kotlin type format
  261. 1 def infer_type(var_name, attribute_name, component_type = nil)
  262. # onTabChange -> ((Int) -> Unit)? (Kotlin callback with Int parameter)
  263. 10 return '((Int) -> Unit)?' if var_name == 'onTabChange' || attribute_name == 'onTabChange'
  264. # onClick, onXxx -> (() -> Unit)? (Kotlin callback type)
  265. 8 return '(() -> Unit)?' if var_name.start_with?('on') && var_name[2]&.match?(/[A-Z]/)
  266. # xxxItems, xxxOptions, xxxList -> List<Any>
  267. 7 return 'List<Any>' if var_name.end_with?('Items', 'Options', 'List', 'Args', 'Subcommands')
  268. # isXxx, hasXxx, canXxx, shouldXxx -> Boolean
  269. 6 return 'Boolean' if var_name.start_with?('is', 'has', 'can', 'should')
  270. # xxxVisibility -> String
  271. 5 return 'String' if var_name.end_with?('Visibility')
  272. # xxxIndex, xxxCount, xxxTab -> Int
  273. 5 return 'Int' if var_name.end_with?('Index', 'Count', 'Tab')
  274. # xxxMargin, xxxPadding -> Dp (Kotlin Compose)
  275. 4 return 'Dp' if var_name.end_with?('Margin', 'Padding')
  276. # Based on attribute name
  277. 4 case attribute_name
  278. when 'onTabChange'
  279. '((Int) -> Unit)?'
  280. when 'onClick', 'onValueChanged', 'onValueChange', 'onTap'
  281. '(() -> Unit)?'
  282. when 'items'
  283. 'CollectionDataSource'
  284. when 'sections'
  285. 'List<Any>'
  286. when 'visibility', 'text', 'fontColor', 'background'
  287. 4 'String'
  288. when 'selectedIndex', 'width', 'height'
  289. 'Int'
  290. when 'hidden', 'enabled', 'disabled'
  291. 'Boolean'
  292. when 'topMargin', 'bottomMargin', 'leftMargin', 'rightMargin', 'startMargin', 'endMargin'
  293. 'Dp'
  294. when 'paddingTop', 'paddingBottom', 'paddingLeft', 'paddingRight', 'paddingStart', 'paddingEnd'
  295. 'Dp'
  296. when 'src', 'srcName'
  297. # NetworkImage uses URL string, Image/CircleImage uses Image type
  298. if component_type&.include?('Network')
  299. 'String'
  300. else
  301. 'Image'
  302. end
  303. else
  304. 'Any'
  305. end
  306. end
  307. 1 def allowed_pattern?(binding)
  308. 133 ALLOWED_PATTERNS.any? { |pattern| binding.match?(pattern) }
  309. end
  310. # Check if visibility attribute is using Boolean instead of String enum
  311. # Valid values: "visible", "gone", "invisible"
  312. # Invalid: true, false, @{booleanProperty}
  313. 1 def check_visibility_type(value, attribute_name, component_type)
  314. 39 return unless attribute_name == 'visibility'
  315. # Check for literal boolean values
  316. 2 if value == true || value == false || value == 'true' || value == 'false'
  317. context = @current_file ? "[#{@current_file}] " : ""
  318. @warnings << "#{context}'#{component_type}.visibility' should use String enum (\"visible\", \"gone\", \"invisible\"), not Boolean. Use a String property in data section with visibility values."
  319. return
  320. end
  321. # Check for binding to boolean property (isXxx, hasXxx, etc.)
  322. 2 if value.is_a?(String) && value.start_with?('@{') && value.end_with?('}')
  323. 2 binding_expr = value[2..-2]
  324. # Check if binding name suggests boolean (isXxx, hasXxx, canXxx, shouldXxx)
  325. 2 if binding_expr.match?(/^(is|has|can|should)[A-Z]/)
  326. context = @current_file ? "[#{@current_file}] " : ""
  327. @warnings << "#{context}'#{component_type}.visibility' binding '@{#{binding_expr}}' appears to be Boolean. Use String property with values: \"visible\", \"gone\", or \"invisible\"."
  328. end
  329. end
  330. end
  331. # Color attributes that should use Color type, not String
  332. COLOR_ATTRIBUTES = %w[
  333. 1 background fontColor borderColor tintColor
  334. disabledBackground disabledFontColor
  335. selectedBackground selectedFontColor
  336. highlightedBackground highlightedFontColor
  337. placeholderColor cursorColor
  338. trackColor progressColor thumbColor
  339. separatorColor indicatorColor
  340. ].freeze
  341. # Check if color attributes have correct type (should be Color, not String)
  342. 1 def check_color_type(binding_expr, attribute_name, component_type)
  343. # Get the base attribute name (without nested path like "shadow.color")
  344. 35 base_attr = attribute_name.split('.').last
  345. # Check if this is a color attribute
  346. 35 return unless COLOR_ATTRIBUTES.include?(base_attr) || base_attr.end_with?('Color')
  347. # Extract the variable name from binding expression
  348. var_name = binding_expr.split('.').first.gsub(/[^a-zA-Z0-9_]/, '')
  349. return if var_name.empty?
  350. # Check the declared type in data
  351. declared_type = @data_types[var_name]
  352. return unless declared_type
  353. # Warn if type is String instead of Color
  354. if declared_type == 'String'
  355. context = @current_file ? "[#{@current_file}] " : ""
  356. @warnings << "#{context}'#{component_type}.#{attribute_name}' binding '@{#{binding_expr}}' has type 'String' but should be 'Color'. Change the data declaration to: { \"name\": \"#{var_name}\", \"class\": \"Color\" }"
  357. end
  358. end
  359. end
  360. end
  361. end

lib/core/config_manager.rb

98.86% lines covered

88 relevant lines. 87 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'json'
  3. 1 require 'pathname'
  4. 1 module KjuiTools
  5. 1 module Core
  6. 1 class ConfigManager
  7. 1 CONFIG_FILE = 'kjui.config.json'
  8. DEFAULT_CONFIG = {
  9. 1 'mode' => 'compose',
  10. 'project_name' => '',
  11. 'package_name' => 'com.example.app',
  12. 'source_directory' => 'app/src/main',
  13. 'layouts_directory' => 'assets/Layouts',
  14. 'styles_directory' => 'assets/Styles',
  15. 'view_directory' => 'kotlin/com/example/app/views',
  16. 'data_directory' => 'kotlin/com/example/app/data',
  17. 'viewmodel_directory' => 'kotlin/com/example/app/viewmodels',
  18. 'extension_directory' => 'library/src/main/kotlin/com/kotlinjsonui/extensions',
  19. 'adapter_directory' => 'library/src/main/kotlin/com/kotlinjsonui/adapters',
  20. 'custom_view_types' => {},
  21. 'compose' => {
  22. 'output_directory' => 'kotlin/com/example/app/generated'
  23. },
  24. 'xml' => {
  25. 'bindings_directory' => 'java/com/example/app/bindings'
  26. }
  27. }.freeze
  28. 1 class << self
  29. 1 def load_config
  30. 133 config_path = find_config_file
  31. 133 base_config = if config_path && File.exist?(config_path)
  32. begin
  33. 26 config_data = JSON.parse(File.read(config_path))
  34. # Store the config directory for use by generators
  35. 25 config_data['_config_dir'] = File.dirname(config_path)
  36. 25 config_data
  37. rescue JSON::ParserError => e
  38. 1 puts "Error parsing config file: #{e.message}"
  39. 1 {}
  40. end
  41. else
  42. 107 {}
  43. end
  44. # Merge with default config to ensure all keys exist
  45. 133 deep_merge(DEFAULT_CONFIG, base_config)
  46. end
  47. # Find config file in project
  48. 1 def find_config_file
  49. # First check current directory
  50. 136 return CONFIG_FILE if File.exist?(CONFIG_FILE)
  51. # Check subdirectories for kjui.config.json
  52. 109 Dir.glob(File.join(Dir.pwd, '**/kjui.config.json')).each do |config_path|
  53. # Skip hidden directories and node_modules
  54. 1 next if config_path.include?('/.') || config_path.include?('/node_modules/')
  55. 1 return config_path
  56. end
  57. # Check parent directories up to 3 levels
  58. 108 current = Dir.pwd
  59. 108 3.times do
  60. 324 current = File.dirname(current)
  61. 324 config_path = File.join(current, CONFIG_FILE)
  62. 324 return config_path if File.exist?(config_path)
  63. end
  64. nil
  65. end
  66. 1 def save_config(config)
  67. 3 File.write(CONFIG_FILE, JSON.pretty_generate(config))
  68. end
  69. # Deep merge two hashes
  70. 1 def deep_merge(hash1, hash2)
  71. 150 hash1.merge(hash2) do |key, old_val, new_val|
  72. 142 if old_val.is_a?(Hash) && new_val.is_a?(Hash)
  73. 15 deep_merge(old_val, new_val)
  74. else
  75. 127 new_val
  76. end
  77. end
  78. end
  79. 1 def config_exists?
  80. 2 File.exist?(CONFIG_FILE)
  81. end
  82. 1 def get(key, default = nil)
  83. 21 config = load_config
  84. 21 keys = key.split('.')
  85. 21 value = config
  86. 21 keys.each do |k|
  87. 22 value = value[k] if value.is_a?(Hash)
  88. end
  89. 21 value || default
  90. end
  91. 1 def set(key, value)
  92. 2 config = load_config
  93. 2 keys = key.split('.')
  94. 2 current = config
  95. 2 keys[0...-1].each do |k|
  96. 1 current[k] ||= {}
  97. 1 current = current[k]
  98. end
  99. 2 current[keys.last] = value
  100. 2 save_config(config)
  101. end
  102. 1 def detect_mode
  103. # Check for Android project files
  104. 7 gradle_files = Dir.glob('build.gradle*')
  105. 7 settings_gradle = Dir.glob('settings.gradle*')
  106. 7 if gradle_files.any? || settings_gradle.any?
  107. # Check if it's a Compose project
  108. 2 build_file = gradle_files.first
  109. 2 if build_file && File.exist?(build_file)
  110. 2 content = File.read(build_file)
  111. 2 if content.include?('compose') || content.include?('androidx.compose')
  112. 1 return 'compose'
  113. end
  114. end
  115. # Default to XML for Android projects
  116. 1 return 'xml'
  117. end
  118. # Default mode
  119. 5 'all'
  120. end
  121. 1 def project_type
  122. 4 mode = get('mode', detect_mode)
  123. 4 case mode
  124. when 'compose'
  125. 1 'Jetpack Compose'
  126. when 'xml'
  127. 1 'Android XML'
  128. when 'all'
  129. 1 'Android (XML + Compose)'
  130. else
  131. 1 'Unknown'
  132. end
  133. end
  134. 1 def source_path
  135. 7 get('source_directory', 'app/src/main')
  136. end
  137. 1 def layouts_path
  138. 1 Pathname.new(source_path).join(get('layouts_directory', 'assets/Layouts'))
  139. end
  140. 1 def styles_path
  141. 1 Pathname.new(source_path).join(get('styles_directory', 'assets/Styles'))
  142. end
  143. 1 def view_path
  144. 1 Pathname.new(source_path).join(get('view_directory', 'java/com/example/app/ui'))
  145. end
  146. 1 def data_path
  147. 1 Pathname.new(source_path).join(get('data_directory', 'java/com/example/app/data'))
  148. end
  149. 1 def viewmodel_path
  150. 1 Pathname.new(source_path).join(get('viewmodel_directory', 'java/com/example/app/viewmodel'))
  151. end
  152. 1 def generated_path
  153. 1 if get('mode') == 'compose'
  154. 1 compose_config = get('compose', {})
  155. 1 Pathname.new(source_path).join(compose_config['output_directory'] || 'java/com/example/app/generated')
  156. else
  157. Pathname.new(source_path).join(get('bindings_directory', 'java/com/example/app/bindings'))
  158. end
  159. end
  160. end
  161. end
  162. end
  163. end

lib/core/json_loader.rb

100.0% lines covered

17 relevant lines. 17 lines covered and 0 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 require 'json'
  3. 1 class JsonLoader
  4. 1 def initialize(config)
  5. 32 @config = config
  6. end
  7. 1 def load_layout(layout_name)
  8. 10 layout_file = find_layout_file(layout_name)
  9. 10 if layout_file && File.exist?(layout_file)
  10. 8 File.read(layout_file)
  11. else
  12. nil
  13. end
  14. end
  15. 1 def load_json(file_path)
  16. 2 if File.exist?(file_path)
  17. 1 File.read(file_path)
  18. else
  19. nil
  20. end
  21. end
  22. 1 private
  23. 1 def find_layout_file(layout_name)
  24. # Remove .json extension if present
  25. 10 layout_name = layout_name.sub(/\.json$/, '')
  26. 10 project_path = @config['project_path'] || Dir.pwd
  27. # Check multiple possible locations
  28. possible_paths = [
  29. 10 File.join(project_path, 'src', 'main', 'assets', 'Layouts', "#{layout_name}.json"),
  30. File.join(project_path, 'app', 'src', 'main', 'assets', 'Layouts', "#{layout_name}.json"),
  31. File.join(project_path, 'Layouts', "#{layout_name}.json"),
  32. File.join(project_path, "#{layout_name}.json")
  33. ]
  34. 32 possible_paths.find { |path| File.exist?(path) }
  35. end
  36. end

lib/core/logger.rb

100.0% lines covered

16 relevant lines. 16 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module KjuiTools
  3. 1 module Core
  4. 1 class Logger
  5. 1 class << self
  6. 1 def info(message)
  7. 82 puts " #{message}"
  8. end
  9. 1 def success(message)
  10. 4 puts "✅ #{message}"
  11. end
  12. 1 def error(message)
  13. 2 puts "❌ #{message}"
  14. end
  15. 1 def warn(message)
  16. 16 puts "⚠️ #{message}"
  17. end
  18. 1 def debug(message)
  19. 73 puts "🔍 #{message}" if ENV['DEBUG']
  20. end
  21. 1 def newline
  22. 1 puts
  23. end
  24. end
  25. end
  26. end
  27. end

lib/core/project_finder.rb

93.22% lines covered

59 relevant lines. 55 lines covered and 4 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'pathname'
  3. 1 require 'find'
  4. 1 module KjuiTools
  5. 1 module Core
  6. 1 class ProjectFinder
  7. 1 class << self
  8. 1 attr_accessor :project_dir, :project_file_path
  9. 1 def setup_paths
  10. 1 @project_dir = find_project_dir
  11. 1 @project_file_path = find_project_file
  12. end
  13. 1 def find_project_dir
  14. # Look for Android project indicators
  15. 4 current_dir = Dir.pwd
  16. # Check current directory
  17. 4 return current_dir if android_project?(current_dir)
  18. # Check parent directory
  19. 3 parent_dir = File.dirname(current_dir)
  20. 3 return parent_dir if android_project?(parent_dir)
  21. # Default to current directory
  22. 2 current_dir
  23. end
  24. 1 def find_project_file
  25. # Look for main build.gradle or build.gradle.kts
  26. 4 gradle_files = Dir.glob('build.gradle*')
  27. 4 return gradle_files.first if gradle_files.any?
  28. # Check parent directory
  29. 2 parent_gradle = Dir.glob('../build.gradle*')
  30. 2 return File.expand_path(parent_gradle.first) if parent_gradle.any?
  31. nil
  32. end
  33. 1 def find_source_directory
  34. # Common Android source directory patterns
  35. 3 common_paths = [
  36. 'app/src/main',
  37. 'src/main',
  38. 'src',
  39. 'app'
  40. ]
  41. 3 project_root = @project_dir || Dir.pwd
  42. 3 common_paths.each do |path|
  43. 7 full_path = File.join(project_root, path)
  44. 7 return path if Dir.exist?(full_path)
  45. end
  46. # Try to find any src directory
  47. 1 Find.find(project_root) do |path|
  48. 1 if File.directory?(path) && File.basename(path) == 'src'
  49. # Check if it contains main directory
  50. main_path = File.join(path, 'main')
  51. if Dir.exist?(main_path)
  52. return Pathname.new(main_path).relative_path_from(Pathname.new(project_root)).to_s
  53. end
  54. return Pathname.new(path).relative_path_from(Pathname.new(project_root)).to_s
  55. end
  56. end
  57. # Default
  58. 1 'app/src/main'
  59. end
  60. 1 def get_full_source_path
  61. 54 @project_dir || Dir.pwd
  62. end
  63. 1 def get_package_name
  64. 1 package_name
  65. end
  66. 1 def package_name
  67. # Try to detect package name from AndroidManifest.xml
  68. 4 manifest_paths = [
  69. 'app/src/main/AndroidManifest.xml',
  70. 'src/main/AndroidManifest.xml',
  71. 'AndroidManifest.xml'
  72. ]
  73. 4 project_root = @project_dir || Dir.pwd
  74. 4 manifest_paths.each do |path|
  75. 10 full_path = File.join(project_root, path)
  76. 10 if File.exist?(full_path)
  77. 1 content = File.read(full_path)
  78. # Extract package name from manifest
  79. 1 if content =~ /package="([^"]+)"/
  80. 1 return $1
  81. end
  82. end
  83. end
  84. # Try to detect from build.gradle
  85. 3 gradle_files = Dir.glob('**/build.gradle*')
  86. 3 gradle_files.each do |gradle_file|
  87. 2 content = File.read(gradle_file)
  88. # Look for namespace first (more reliable)
  89. 2 if content =~ /namespace\s*=\s*["']([^"']+)["']/
  90. 1 return $1
  91. end
  92. # Look for applicationId
  93. 1 if content =~ /applicationId\s*=\s*["']([^"']+)["']/
  94. 1 return $1
  95. end
  96. end
  97. # Default package name
  98. 1 'com.example.app'
  99. end
  100. 1 private
  101. 1 def android_project?(dir)
  102. # Check for Android project indicators
  103. 7 indicators = [
  104. 'build.gradle',
  105. 'build.gradle.kts',
  106. 'settings.gradle',
  107. 'settings.gradle.kts',
  108. 'gradlew',
  109. 'app/build.gradle',
  110. 'app/build.gradle.kts'
  111. ]
  112. 46 indicators.any? { |indicator| File.exist?(File.join(dir, indicator)) }
  113. end
  114. end
  115. end
  116. end
  117. end

lib/core/resources/color_manager.rb

87.6% lines covered

363 relevant lines. 318 lines covered and 45 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'json'
  3. 1 require 'fileutils'
  4. 1 require 'rexml/document'
  5. 1 require 'rexml/formatters/pretty'
  6. 1 require_relative '../logger'
  7. 1 module KjuiTools
  8. 1 module Core
  9. 1 module Resources
  10. 1 class ColorManager
  11. 1 def initialize(config, source_path, resources_dir)
  12. 82 @config = config
  13. 82 @source_path = source_path
  14. 82 @resources_dir = resources_dir
  15. 82 @colors_file = File.join(@resources_dir, 'colors.json')
  16. 82 @defined_colors_file = File.join(@resources_dir, 'defined_colors.json')
  17. 82 @extracted_colors = {}
  18. 82 @undefined_colors = {}
  19. 82 @colors_data = load_colors_json
  20. 82 @defined_colors_data = load_defined_colors_json
  21. end
  22. # Main process method called from ResourcesManager
  23. 1 def process_colors(processed_files, processed_count, skipped_count, config)
  24. 11 return if processed_files.empty?
  25. 10 Core::Logger.info "Extracting colors from #{processed_count} files (#{skipped_count} skipped)..."
  26. # Extract colors from JSON files
  27. 10 extract_colors(processed_files)
  28. # Save updated colors.json if there are new colors
  29. 10 save_colors_json if @extracted_colors.any?
  30. # Save undefined colors to defined_colors.json
  31. 10 save_defined_colors_json if @undefined_colors.any?
  32. # Generate ColorManager.kt if needed
  33. 10 generate_color_manager_kotlin if @config['resource_manager_directory']
  34. end
  35. # Apply extracted colors to color resources
  36. 1 def apply_to_color_assets
  37. # Save any pending colors to colors.json
  38. 11 save_colors_json if @extracted_colors.any?
  39. # Save undefined colors to defined_colors.json
  40. 11 save_defined_colors_json if @undefined_colors.any?
  41. # Apply colors to Android colors.xml
  42. 11 apply_to_colors_xml
  43. end
  44. 1 private
  45. # Load existing colors.json file
  46. 1 def load_colors_json
  47. 93 return {} unless File.exist?(@colors_file)
  48. begin
  49. 10 JSON.parse(File.read(@colors_file))
  50. 1 rescue JSON::ParserError => e
  51. 1 Core::Logger.warn "Failed to parse colors.json: #{e.message}"
  52. 1 {}
  53. end
  54. end
  55. # Load existing defined_colors.json file
  56. 1 def load_defined_colors_json
  57. 93 return {} unless File.exist?(@defined_colors_file)
  58. begin
  59. 6 JSON.parse(File.read(@defined_colors_file))
  60. 1 rescue JSON::ParserError => e
  61. 1 Core::Logger.warn "Failed to parse defined_colors.json: #{e.message}"
  62. 1 {}
  63. end
  64. end
  65. # Save colors data to colors.json
  66. 1 def save_colors_json
  67. # Merge extracted colors with existing colors
  68. 2 @colors_data.merge!(@extracted_colors)
  69. # Ensure Resources directory exists
  70. 2 FileUtils.mkdir_p(@resources_dir)
  71. # Write colors.json
  72. 2 File.write(@colors_file, JSON.pretty_generate(@colors_data))
  73. 2 Core::Logger.info "Updated colors.json with #{@extracted_colors.size} new colors"
  74. # Clear extracted colors after saving
  75. 2 @extracted_colors.clear
  76. end
  77. # Apply colors to Android colors.xml file
  78. 1 def apply_to_colors_xml
  79. 11 colors_xml_path = File.join(@source_path, @config['source_directory'] || 'src/main', 'res/values/colors.xml')
  80. 11 unless File.exist?(colors_xml_path)
  81. 8 Core::Logger.info "colors.xml not found at: #{colors_xml_path}, creating new file"
  82. # Ensure the directory exists
  83. 8 colors_dir = File.dirname(colors_xml_path)
  84. 8 FileUtils.mkdir_p(colors_dir)
  85. # Create a new colors.xml with basic structure
  86. 8 default_xml = <<~XML
  87. <?xml version="1.0" encoding="utf-8"?>
  88. <resources>
  89. </resources>
  90. XML
  91. 8 File.write(colors_xml_path, default_xml)
  92. end
  93. # Load colors from colors.json
  94. 11 all_colors = load_colors_json
  95. # Also include defined colors (only those with actual values that don't already exist)
  96. 11 defined_colors = load_defined_colors_json
  97. 11 defined_colors.each do |key, value|
  98. # Only add if value exists and all_colors doesn't already have this key
  99. 3 all_colors[key] = value if value && !all_colors.key?(key)
  100. end
  101. 11 return if all_colors.empty?
  102. # Read and parse existing colors.xml
  103. 4 xml_content = File.read(colors_xml_path)
  104. 4 doc = REXML::Document.new(xml_content)
  105. 4 resources = doc.root
  106. 4 unless resources
  107. Core::Logger.error "Invalid colors.xml structure"
  108. return
  109. end
  110. # Build a hash of existing colors for faster lookup
  111. 4 existing_colors = {}
  112. 4 resources.elements.each('color') do |elem|
  113. 1 name = elem.attributes['name']
  114. 1 existing_colors[name] = elem if name
  115. end
  116. # Add or update colors
  117. 4 colors_added = 0
  118. 4 colors_updated = 0
  119. 4 all_colors.each do |key, value|
  120. # Skip if value is nil or not a hex color
  121. 6 next unless value && value.is_a?(String) && value.match?(/^#?[A-Fa-f0-9]{6,8}$/)
  122. # Normalize hex color (ensure it has # and is uppercase)
  123. 6 hex_value = value.start_with?('#') ? value.upcase : "##{value.upcase}"
  124. # Convert 6-digit hex to 8-digit ARGB format if needed (Android requires ARGB)
  125. 6 if hex_value.length == 7 # #RRGGBB
  126. 6 hex_value = "#FF#{hex_value[1..-1]}" # Add full opacity
  127. end
  128. 6 if existing_colors[key]
  129. # Update existing color if value is different
  130. current_value = existing_colors[key].text
  131. if current_value != hex_value
  132. existing_colors[key].text = hex_value
  133. colors_updated += 1
  134. end
  135. else
  136. # Add new color element
  137. 6 color_elem = REXML::Element.new('color')
  138. 6 color_elem.add_attribute('name', key)
  139. 6 color_elem.text = hex_value
  140. 6 resources.add_element(color_elem)
  141. 6 colors_added += 1
  142. end
  143. end
  144. 4 if colors_added > 0 || colors_updated > 0
  145. # Format and write back
  146. 4 formatter = REXML::Formatters::Pretty.new(2)
  147. 4 formatter.compact = true
  148. 4 output = String.new
  149. 4 formatter.write(doc, output)
  150. 4 File.write(colors_xml_path, output)
  151. 4 Core::Logger.info "Updated colors.xml: #{colors_added} added, #{colors_updated} updated"
  152. end
  153. end
  154. # Save undefined colors to defined_colors.json
  155. 1 def save_defined_colors_json
  156. # Merge new undefined colors with existing defined colors
  157. @defined_colors_data.merge!(@undefined_colors)
  158. # Ensure Resources directory exists
  159. FileUtils.mkdir_p(@resources_dir)
  160. # Write defined_colors.json
  161. File.write(@defined_colors_file, JSON.pretty_generate(@defined_colors_data))
  162. Core::Logger.info "Updated defined_colors.json with #{@undefined_colors.size} undefined color keys"
  163. # Clear undefined colors after saving
  164. @undefined_colors.clear
  165. end
  166. # Extract color values from processed JSON files
  167. 1 def extract_colors(processed_files)
  168. 10 @modified_files = []
  169. 10 Core::Logger.debug "Processing #{processed_files.size} files for colors"
  170. 10 processed_files.each do |json_file|
  171. begin
  172. 10 Core::Logger.debug "Processing file: #{json_file}"
  173. 10 content = File.read(json_file)
  174. 10 data = JSON.parse(content)
  175. # Extract and replace colors recursively from JSON structure
  176. 10 modified = replace_colors_recursive(data)
  177. 10 Core::Logger.debug "File modified: #{modified}, extracted colors: #{@extracted_colors.size}"
  178. # Save modified JSON file if any colors were replaced
  179. 10 if modified
  180. 2 File.write(json_file, JSON.pretty_generate(data))
  181. 2 @modified_files << json_file
  182. 2 Core::Logger.debug "Updated colors in: #{json_file}"
  183. end
  184. rescue JSON::ParserError => e
  185. Core::Logger.warn "Failed to parse #{json_file}: #{e.message}"
  186. rescue => e
  187. Core::Logger.error "Error processing #{json_file}: #{e.message}"
  188. end
  189. end
  190. 10 if @modified_files.any?
  191. 2 Core::Logger.info "Replaced colors in #{@modified_files.size} files"
  192. end
  193. end
  194. # Replace colors recursively in JSON data
  195. 1 def replace_colors_recursive(data, parent_key = nil)
  196. 40 modified = false
  197. 40 case data
  198. when Hash
  199. # Check if this is a data property with class: Color
  200. 29 if data['class'] == 'Color' && data['defaultValue'].is_a?(String)
  201. 3 value = data['defaultValue']
  202. # Skip binding expressions (starting with @{)
  203. 3 unless value.start_with?('@{') && value.end_with?('}')
  204. 2 new_value = process_and_replace_color(value)
  205. 2 if new_value != value
  206. 2 data['defaultValue'] = new_value
  207. 2 modified = true
  208. 2 Core::Logger.debug "Replaced data defaultValue #{value} with #{new_value}"
  209. end
  210. end
  211. end
  212. 29 data.each do |key, value|
  213. # Check if this key is a color property and value is a string
  214. 51 if is_color_property?(key) && value.is_a?(String)
  215. # Skip binding expressions (starting with @{)
  216. 7 if value.start_with?('@{') && value.end_with?('}')
  217. 2 Core::Logger.debug "Skipping binding expression: #{value}"
  218. 2 next
  219. end
  220. # Process and replace the color value (hex or string key)
  221. 5 new_value = process_and_replace_color(value)
  222. 5 if new_value != value
  223. 5 data[key] = new_value
  224. 5 modified = true
  225. 5 Core::Logger.debug "Replaced #{value} with #{new_value} in #{key}"
  226. end
  227. 44 elsif value.is_a?(Hash) || value.is_a?(Array)
  228. # Recurse into nested structures
  229. 11 child_modified = replace_colors_recursive(value, key)
  230. 11 modified ||= child_modified
  231. end
  232. end
  233. when Array
  234. 11 data.each_with_index do |item, index|
  235. 10 if item.is_a?(Hash) || item.is_a?(Array)
  236. 10 child_modified = replace_colors_recursive(item, parent_key)
  237. 10 modified ||= child_modified
  238. end
  239. end
  240. end
  241. 40 modified
  242. end
  243. # Check if a property name is likely to contain a color
  244. 1 def is_color_property?(key)
  245. # Based on actual XML style_mapper.rb and Compose components
  246. 66 color_properties = [
  247. # Common background/appearance (style_mapper.rb)
  248. 'background',
  249. 'backgroundColor',
  250. 'borderColor',
  251. 'strokeColor',
  252. # Text colors (text_mapper.rb)
  253. 'fontColor',
  254. 'textColor',
  255. 'color', # Generic color that can map to textColor or tint
  256. # State-specific backgrounds (drawable generation)
  257. 'disabledBackground',
  258. 'tapBackground',
  259. 'pressedBackground',
  260. 'selectedBackground',
  261. 'focusedBackground',
  262. 'checkedBackground',
  263. 'rippleColor',
  264. # Input/SelectBox specific (input_mapper.rb, SelectBox component)
  265. 'hintColor',
  266. 'cancelButtonBackgroundColor',
  267. 'cancelButtonTextColor',
  268. # Image/Icon tinting
  269. 'tint',
  270. 'tintColor',
  271. # Gradient colors (style_mapper.rb)
  272. 'gradientStartColor',
  273. 'startColor',
  274. 'gradientEndColor',
  275. 'endColor',
  276. 'gradientCenterColor',
  277. 'centerColor',
  278. # Blur overlay
  279. 'blurOverlayColor',
  280. # Shadow
  281. 'shadowColor'
  282. ]
  283. 66 color_properties.include?(key.to_s)
  284. end
  285. # Process and replace a color value, returning the color key
  286. 1 def process_and_replace_color(color_value)
  287. # Skip data binding expressions
  288. 12 return color_value if color_value.is_a?(String) && color_value.start_with?('@{')
  289. # Handle hex colors
  290. 10 if is_hex_color?(color_value)
  291. # Check if it's a fully transparent color (alpha = 00)
  292. 9 if is_transparent_color?(color_value)
  293. # Add transparent to colors.json if not already present
  294. unless @colors_data.key?('transparent') || @extracted_colors.key?('transparent')
  295. @extracted_colors['transparent'] = '#00000000'
  296. end
  297. return 'transparent'
  298. end
  299. # Normalize hex color (uppercase, with #)
  300. 9 hex_color = normalize_hex_color(color_value)
  301. # Check if color already exists in colors.json
  302. 9 existing_key = find_color_key(hex_color)
  303. 9 if existing_key
  304. # Color already exists, return the key
  305. 3 Core::Logger.debug "Found existing color: #{existing_key} = #{hex_color}"
  306. 3 return existing_key
  307. else
  308. # Generate a new key for this color
  309. 6 new_key = generate_color_key(hex_color)
  310. # Add to extracted colors
  311. 6 @extracted_colors[new_key] = hex_color
  312. 6 Core::Logger.debug "New color found: #{new_key} = #{hex_color}"
  313. 6 return new_key
  314. end
  315. # Handle string color keys
  316. 1 elsif color_value.is_a?(String) && !color_value.empty?
  317. # Check if this color key exists in colors.json
  318. 1 if @colors_data.key?(color_value) || @extracted_colors.key?(color_value)
  319. # Color key exists, keep it as is
  320. Core::Logger.debug "Color key exists: #{color_value}"
  321. return color_value
  322. 1 elsif @defined_colors_data.key?(color_value)
  323. # Already in defined_colors, keep it as is
  324. Core::Logger.debug "Color key already in defined_colors: #{color_value}"
  325. return color_value
  326. else
  327. # Undefined color key, add to undefined colors list
  328. 1 @undefined_colors[color_value] = nil
  329. 1 Core::Logger.debug "Undefined color key found: #{color_value}"
  330. 1 return color_value
  331. end
  332. else
  333. # Return as is for other types
  334. return color_value
  335. end
  336. end
  337. # Find existing key for a hex color
  338. 1 def find_color_key(hex_color)
  339. # Check both existing colors and newly extracted colors
  340. 9 all_colors = @colors_data.merge(@extracted_colors)
  341. 13 all_colors.find { |key, value| value.upcase == hex_color.upcase }&.first
  342. end
  343. # Generate a descriptive key name based on RGB values
  344. 1 def generate_color_key(hex_color)
  345. # Parse RGB values from hex
  346. 12 rgb = parse_hex_to_rgb(hex_color)
  347. 12 return 'unknown_color' unless rgb
  348. 12 r, g, b = rgb
  349. # Calculate brightness and dominant color
  350. 12 brightness = (r + g + b) / 3.0
  351. # Determine base name from brightness
  352. 12 base_name = if brightness > 230
  353. 1 'white'
  354. 11 elsif brightness > 200
  355. 'pale'
  356. 11 elsif brightness > 150
  357. 'light'
  358. 11 elsif brightness > 100
  359. 1 'medium'
  360. 10 elsif brightness > 50
  361. 7 'dark'
  362. 3 elsif brightness > 20
  363. 'deep'
  364. else
  365. 3 'black'
  366. end
  367. # Find dominant color if not grayscale
  368. 12 max_diff = [r, g, b].max - [r, g, b].min
  369. 12 if max_diff > 30 # Not grayscale
  370. # Determine dominant color
  371. 7 if r > g && r > b
  372. 7 if r - g > 50 && r - b > 50
  373. 7 color_suffix = '_red'
  374. elsif r > b
  375. color_suffix = '_orange' if g > b
  376. color_suffix = '_pink' if b > g * 0.7
  377. else
  378. color_suffix = '_magenta'
  379. end
  380. elsif g > r && g > b
  381. if g - r > 50 && g - b > 50
  382. color_suffix = '_green'
  383. elsif g > b && r > b * 0.7
  384. color_suffix = '_yellow'
  385. else
  386. color_suffix = '_lime'
  387. end
  388. elsif b > r && b > g
  389. if b - r > 50 && b - g > 50
  390. color_suffix = '_blue'
  391. elsif b > r && g > r * 0.7
  392. color_suffix = '_cyan'
  393. else
  394. color_suffix = '_purple'
  395. end
  396. else
  397. color_suffix = ''
  398. end
  399. 7 base_name = base_name + color_suffix unless base_name == 'white' || base_name == 'black'
  400. 5 elsif base_name != 'white' && base_name != 'black'
  401. 1 base_name = base_name + '_gray'
  402. end
  403. # Handle duplicates by adding suffix
  404. 12 final_key = base_name
  405. 12 counter = 2
  406. 12 all_colors = @colors_data.merge(@extracted_colors)
  407. 12 while all_colors.key?(final_key)
  408. 1 final_key = "#{base_name}_#{counter}"
  409. 1 counter += 1
  410. end
  411. 12 final_key
  412. end
  413. # Parse hex color to RGB values (and alpha if present)
  414. 1 def parse_hex_to_rgb(hex_color)
  415. # Remove # if present
  416. 18 hex = hex_color.gsub('#', '')
  417. # Support both 3 and 6 digit hex
  418. 18 if hex.length == 3
  419. 4 hex = hex.chars.map { |c| c * 2 }.join
  420. end
  421. # Handle 8-digit hex (ARGB) - extract RGB part
  422. 18 if hex.length == 8
  423. # Skip alpha channel (first 2 characters) for RGB analysis
  424. 1 hex = hex[2..7]
  425. end
  426. 18 return nil unless hex.length == 6
  427. [
  428. 17 hex[0..1].to_i(16),
  429. hex[2..3].to_i(16),
  430. hex[4..5].to_i(16)
  431. ]
  432. rescue
  433. nil
  434. end
  435. # Check if a value is a hex color
  436. 1 def is_hex_color?(value)
  437. 20 return false unless value.is_a?(String)
  438. # Support 3, 6, and 8 character hex colors (8 = ARGB with alpha)
  439. 18 value.match?(/^#?[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?([0-9A-Fa-f]{2})?$/)
  440. end
  441. # Check if a color is fully transparent (alpha = 00)
  442. 1 def is_transparent_color?(value)
  443. 9 return false unless value.is_a?(String)
  444. 9 hex = value.gsub('#', '').upcase
  445. # Only 8-digit hex colors have alpha channel (ARGB format for Android)
  446. 9 return false unless hex.length == 8
  447. # Check if alpha is 00 (fully transparent) - first 2 chars for ARGB
  448. alpha = hex[0..1]
  449. alpha == '00'
  450. end
  451. # Normalize hex color format
  452. 1 def normalize_hex_color(hex_color)
  453. 13 hex = hex_color.gsub('#', '').upcase
  454. # Convert 3-digit to 6-digit
  455. 13 if hex.length == 3
  456. 4 hex = hex.chars.map { |c| c * 2 }.join
  457. end
  458. # Keep 8-digit (ARGB) as is
  459. # 6-digit and 8-digit are both valid
  460. 13 "##{hex}"
  461. end
  462. # Generate Kotlin code for ColorManager
  463. 1 def generate_color_manager_kotlin
  464. 1 return unless @config['resource_manager_directory']
  465. 1 resource_manager_dir = File.join(@source_path, @config['resource_manager_directory'])
  466. 1 FileUtils.mkdir_p(resource_manager_dir)
  467. 1 output_file = File.join(resource_manager_dir, 'ColorManager.kt')
  468. # Combine all colors (from colors.json and defined_colors.json)
  469. 1 all_colors = @colors_data.dup
  470. # Add defined colors (keys without values yet)
  471. 1 @defined_colors_data.each do |key, _|
  472. all_colors[key] ||= nil
  473. end
  474. 1 kotlin_code = generate_kotlin_code(all_colors)
  475. 1 File.write(output_file, kotlin_code)
  476. 1 Core::Logger.info "✓ Generated ColorManager.kt"
  477. end
  478. 1 def generate_kotlin_code(colors)
  479. 12 timestamp = Time.now.strftime('%Y-%m-%d %H:%M:%S')
  480. 12 code = []
  481. 12 code << "// ColorManager.kt"
  482. 12 code << "// Auto-generated file - DO NOT EDIT"
  483. 12 code << "// Generated at: #{timestamp}"
  484. 12 code << ""
  485. 12 code << "package com.kotlinjsonui.generated"
  486. 12 code << ""
  487. 12 code << "import android.graphics.Color"
  488. 12 code << "import android.util.Log"
  489. 12 code << "import androidx.compose.ui.graphics.Color as ComposeColor"
  490. 12 code << ""
  491. 12 code << "object ColorManager {"
  492. 12 code << " private const val TAG = \"ColorManager\""
  493. 12 code << " "
  494. 12 code << " // Load colors from colors.json"
  495. 12 if @colors_data.empty?
  496. 11 code << " private val colorsData: Map<String, String> = emptyMap()"
  497. else
  498. 1 code << " private val colorsData: Map<String, String> = mapOf("
  499. # Add defined colors from colors.json
  500. 1 @colors_data.each_with_index do |(key, hex_value), index|
  501. 2 comma = index < @colors_data.size - 1 ? "," : ""
  502. 2 code << " \"#{key}\" to \"#{hex_value}\"#{comma}"
  503. end
  504. 1 code << " )"
  505. end
  506. 12 code << ""
  507. 12 code << " // Android Views colors"
  508. 12 code << " object views {"
  509. 12 code << " // Get Color by key (returns null for binding expressions like @{...})"
  510. 12 code << " fun color(key: String): Int? {"
  511. 12 code << " // Skip binding expressions"
  512. 12 code << " if (key.startsWith(\"@{\") && key.endsWith(\"}\")) {"
  513. 12 code << " return null"
  514. 12 code << " }"
  515. 12 code << " val hexString = colorsData[key]"
  516. 12 code << " if (hexString == null) {"
  517. 12 code << " Log.w(TAG, \"Color key '$key' not found in colors.json\")"
  518. 12 code << " return try {"
  519. 12 code << " Color.parseColor(key) // Try to parse key as hex color"
  520. 12 code << " } catch (e: IllegalArgumentException) {"
  521. 12 code << " null"
  522. 12 code << " }"
  523. 12 code << " }"
  524. 12 code << " return try {"
  525. 12 code << " Color.parseColor(hexString)"
  526. 12 code << " } catch (e: IllegalArgumentException) {"
  527. 12 code << " Log.w(TAG, \"Invalid color format '$hexString' for key '$key'\")"
  528. 12 code << " null"
  529. 12 code << " }"
  530. 12 code << " }"
  531. 12 code << ""
  532. # Generate static color accessors for Android Views
  533. 12 colors.keys.sort.each do |key|
  534. 12 property_name = snake_to_camel(key)
  535. 12 code << " val #{property_name}: Int?"
  536. 12 code << " get() {"
  537. 12 if @colors_data[key]
  538. 2 code << " return try {"
  539. 2 code << " Color.parseColor(\"#{@colors_data[key]}\")"
  540. 2 code << " } catch (e: IllegalArgumentException) {"
  541. 2 code << " Log.w(TAG, \"Invalid color format '#{@colors_data[key]}' for '#{key}'\")"
  542. 2 code << " null"
  543. 2 code << " }"
  544. else
  545. 10 code << " // Undefined color - needs to be defined in colors.json"
  546. 10 code << " Log.w(TAG, \"Color '#{key}' is not defined in colors.json\")"
  547. 10 code << " return null"
  548. end
  549. 12 code << " }"
  550. 12 code << ""
  551. end
  552. 12 code << " }"
  553. 12 code << ""
  554. 12 code << " // Jetpack Compose colors"
  555. 12 code << " object compose {"
  556. 12 code << " // Get Compose Color by key (returns null for binding expressions like @{...})"
  557. 12 code << " fun color(key: String): ComposeColor? {"
  558. 12 code << " // Skip binding expressions"
  559. 12 code << " if (key.startsWith(\"@{\") && key.endsWith(\"}\")) {"
  560. 12 code << " return null"
  561. 12 code << " }"
  562. 12 code << " val androidColor = views.color(key) ?: return null"
  563. 12 code << " return ComposeColor(androidColor)"
  564. 12 code << " }"
  565. 12 code << ""
  566. # Generate static Compose Color accessors
  567. 12 colors.keys.sort.each do |key|
  568. 12 property_name = snake_to_camel(key)
  569. 12 code << " val #{property_name}: ComposeColor?"
  570. 12 code << " get() {"
  571. 12 code << " val androidColor = views.#{property_name} ?: return null"
  572. 12 code << " return ComposeColor(androidColor)"
  573. 12 code << " }"
  574. 12 code << ""
  575. end
  576. 12 code << " }"
  577. 12 code << "}"
  578. 12 code << ""
  579. 12 code << "// Note: Color parsing extensions are provided by KotlinJsonUI library"
  580. 12 code.join("\n")
  581. end
  582. 1 def snake_to_camel(snake_case)
  583. # Convert snake_case to camelCase
  584. # Examples:
  585. # primary_blue -> primaryBlue
  586. # white_2 -> white2
  587. # dark_gray -> darkGray
  588. 28 parts = snake_case.split('_')
  589. 28 first_part = parts.shift
  590. 28 camel = first_part + parts.map(&:capitalize).join
  591. 28 camel
  592. end
  593. end
  594. end
  595. end
  596. end

lib/core/resources/string_manager.rb

61.6% lines covered

237 relevant lines. 146 lines covered and 91 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'json'
  3. 1 require 'fileutils'
  4. 1 require 'rexml/document'
  5. 1 require_relative '../logger'
  6. 1 module KjuiTools
  7. 1 module Core
  8. 1 module Resources
  9. 1 class StringManager
  10. 1 def initialize(config, source_path, resources_dir)
  11. 48 @config = config
  12. 48 @source_path = source_path
  13. 48 @resources_dir = resources_dir
  14. 48 @strings_file = File.join(@resources_dir, 'strings.json')
  15. 48 @extracted_strings = {} # Structure: { "filename": { "key": "value" } }
  16. 48 @strings_data = load_strings_json
  17. end
  18. # Main process method called from ResourcesManager
  19. 1 def process_strings(processed_files, processed_count, skipped_count)
  20. 9 return if processed_files.empty?
  21. 8 Core::Logger.info "Extracting strings from #{processed_count} files (#{skipped_count} skipped)..."
  22. # Extract strings from JSON files
  23. 8 extract_strings(processed_files)
  24. # Save updated strings.json if there are new strings
  25. 8 save_strings_json if @extracted_strings.any?
  26. # Generate StringManager.kt if needed
  27. # Disabled: StringManager.kt generation is not needed
  28. # generate_string_manager_kotlin if @config['resource_manager_directory']
  29. end
  30. # Apply extracted strings to strings.xml files
  31. 1 def apply_to_strings_files
  32. 9 return if @strings_data.empty?
  33. # Get string files from config
  34. 5 string_files = @config['string_files'] || []
  35. 5 if string_files.empty?
  36. # Default: update strings.xml for default language
  37. 5 update_strings_xml('values')
  38. else
  39. # Update configured string files
  40. string_files.each do |string_file_path|
  41. # Extract values directory from path (e.g., "res/values-ja/strings.xml" -> "values-ja")
  42. if string_file_path =~ /res\/(values[^\/]*)\//
  43. lang_dir = $1
  44. update_strings_xml(lang_dir)
  45. elsif string_file_path =~ /(values[^\/]*)\//
  46. lang_dir = $1
  47. update_strings_xml(lang_dir)
  48. else
  49. # If no standard pattern, try to use the parent directory name
  50. parts = string_file_path.split('/')
  51. if parts.length >= 2
  52. lang_dir = parts[-2]
  53. update_strings_xml(lang_dir) if lang_dir.start_with?('values')
  54. end
  55. end
  56. end
  57. end
  58. end
  59. 1 private
  60. # Load existing strings.json file
  61. 1 def load_strings_json
  62. 48 return {} unless File.exist?(@strings_file)
  63. begin
  64. JSON.parse(File.read(@strings_file))
  65. rescue JSON::ParserError => e
  66. Core::Logger.warn "Failed to parse strings.json: #{e.message}"
  67. {}
  68. end
  69. end
  70. # Save strings data to strings.json
  71. 1 def save_strings_json
  72. # Count total new strings
  73. 5 total_new_strings = 0
  74. 5 @extracted_strings.each do |file_prefix, file_strings|
  75. 5 total_new_strings += file_strings.size
  76. end
  77. # Merge extracted strings with existing strings
  78. 5 @extracted_strings.each do |file_prefix, file_strings|
  79. 5 @strings_data[file_prefix] ||= {}
  80. 5 @strings_data[file_prefix].merge!(file_strings)
  81. end
  82. # Ensure Resources directory exists
  83. 5 FileUtils.mkdir_p(@resources_dir)
  84. # Write strings.json
  85. 5 File.write(@strings_file, JSON.pretty_generate(@strings_data))
  86. 5 Core::Logger.info "Updated strings.json with #{total_new_strings} new strings"
  87. # Clear extracted strings after saving
  88. 5 @extracted_strings.clear
  89. end
  90. # Extract string values from processed JSON files
  91. 1 def extract_strings(processed_files)
  92. 8 @modified_files = []
  93. 8 Core::Logger.debug "Processing #{processed_files.size} files for strings"
  94. # Get the layouts directory to calculate relative paths
  95. 8 layouts_dir = File.join(@source_path, @config['source_directory'] || 'src/main', 'assets/Layouts')
  96. 8 processed_files.each do |json_file|
  97. begin
  98. 8 Core::Logger.debug "Processing file: #{json_file}"
  99. 8 content = File.read(json_file)
  100. 8 data = JSON.parse(content)
  101. # Get file prefix from relative path
  102. 8 relative_path = Pathname.new(json_file).relative_path_from(Pathname.new(layouts_dir)).to_s
  103. 8 file_prefix = generate_file_prefix(relative_path)
  104. # Create current file strings container if not exists
  105. 8 @current_file_strings = {}
  106. # Extract strings recursively from JSON structure (without modifying)
  107. 8 extract_strings_recursive(data, nil, file_prefix)
  108. # Store extracted strings for this file if any
  109. 8 if @current_file_strings.any?
  110. 5 @extracted_strings[file_prefix] ||= {}
  111. 5 @extracted_strings[file_prefix].merge!(@current_file_strings)
  112. 5 Core::Logger.debug "Extracted #{@current_file_strings.size} strings from #{file_prefix}"
  113. end
  114. # NOTE: We don't modify the original JSON files anymore
  115. # The resource resolution happens during code generation
  116. rescue JSON::ParserError => e
  117. Core::Logger.warn "Failed to parse #{json_file}: #{e.message}"
  118. rescue => e
  119. Core::Logger.error "Error processing #{json_file}: #{e.message}"
  120. end
  121. end
  122. 8 if @modified_files.any?
  123. Core::Logger.info "Replaced strings in #{@modified_files.size} files"
  124. end
  125. end
  126. # Generate file prefix from relative path
  127. 1 def generate_file_prefix(relative_path)
  128. # Remove .json extension and replace / with _
  129. # Examples:
  130. # "test.json" -> "test"
  131. # "subdir/test.json" -> "subdir_test"
  132. # "a/b/c/test.json" -> "a_b_c_test"
  133. 11 relative_path
  134. .gsub(/\.json$/, '')
  135. .gsub('/', '_')
  136. end
  137. # Extract strings recursively from JSON data (without modifying)
  138. 1 def extract_strings_recursive(data, parent_key = nil, file_prefix = nil)
  139. 17 case data
  140. when Hash
  141. 12 data.each do |key, value|
  142. # Special handling for partialAttributes
  143. 25 if key == 'partialAttributes' && value.is_a?(Array)
  144. value.each do |partial_attr|
  145. if partial_attr.is_a?(Hash) && partial_attr['range'].is_a?(String)
  146. # Process range text when it's a string (not an array)
  147. range_text = partial_attr['range']
  148. if !range_text.empty? && should_extract_string?(range_text)
  149. extract_and_store_string(range_text, file_prefix)
  150. end
  151. end
  152. end
  153. # Regular string property handling
  154. 25 elsif is_string_property?(key) && value.is_a?(String) && !value.empty?
  155. # Extract the string value
  156. 5 if should_extract_string?(value)
  157. 5 extract_and_store_string(value, file_prefix)
  158. end
  159. 20 elsif value.is_a?(Hash) || value.is_a?(Array)
  160. # Recurse into nested structures
  161. 5 extract_strings_recursive(value, key, file_prefix)
  162. end
  163. end
  164. when Array
  165. 5 data.each_with_index do |item, index|
  166. 4 if item.is_a?(Hash) || item.is_a?(Array)
  167. 4 extract_strings_recursive(item, parent_key, file_prefix)
  168. end
  169. end
  170. end
  171. end
  172. # Check if a property name is likely to contain a localizable string
  173. 1 def is_string_property?(key)
  174. # Based on actual XML mapper and Compose components code
  175. 32 string_properties = [
  176. 'text', # Text, Button, TextField, TextView, Checkbox
  177. 'hint', # TextField, SelectBox (both XML and Compose)
  178. 'placeholder', # TextField, SelectBox alternative to hint
  179. 'label', # Checkbox label
  180. 'prompt' # SelectBox (maps to placeholder in XML)
  181. ]
  182. 32 string_properties.include?(key.to_s)
  183. end
  184. # Check if a string should be extracted for localization
  185. 1 def should_extract_string?(value)
  186. # Skip data binding expressions
  187. 12 return false if value.start_with?('@{') || value.start_with?('${')
  188. # Skip if it's already a snake_case key (already converted)
  189. # This prevents re-extracting strings that have been replaced with keys
  190. 10 return false if value.match?(/^[a-z]+(_[a-z0-9]+)*$/)
  191. # Extract if it's a regular text string longer than 2 characters
  192. # and contains alphabetic characters
  193. 8 value.length > 2 && value.match?(/[a-zA-Z]/)
  194. end
  195. # Extract and store string (without returning a key)
  196. 1 def extract_and_store_string(value, file_prefix = nil)
  197. # Generate a snake_case key from the text
  198. 5 key = generate_string_key(value)
  199. # Check if this exact string already has a key in this file
  200. 5 existing_key = find_string_key_in_file(value, file_prefix)
  201. 5 if existing_key
  202. Core::Logger.debug "String already extracted: #{existing_key}"
  203. return
  204. end
  205. # Add to current file strings
  206. 5 @current_file_strings[key] = value
  207. 5 Core::Logger.debug "New string extracted: #{key} = '#{value}'"
  208. end
  209. # Find existing key for a string value in a specific file
  210. 1 def find_string_key_in_file(value, file_prefix)
  211. 5 return nil unless file_prefix
  212. # Check if this file has been processed before
  213. 5 if @strings_data[file_prefix]
  214. # Look for existing key in this file's strings
  215. @strings_data[file_prefix].find { |key, val| val == value }&.first
  216. end
  217. # Also check current file's strings being extracted
  218. 5 if @current_file_strings
  219. 5 found_key = @current_file_strings.find { |key, val| val == value }&.first
  220. 5 return "#{file_prefix}_#{found_key}" if found_key
  221. end
  222. nil
  223. end
  224. # Find existing key for a string value (legacy method)
  225. 1 def find_string_key(value)
  226. # Check both existing strings and newly extracted strings
  227. all_strings = @strings_data.merge(@extracted_strings)
  228. all_strings.find { |key, val| val == value }&.first
  229. end
  230. # Generate a snake_case key from text
  231. 1 def generate_string_key(text)
  232. # Convert to snake_case
  233. 10 base_key = text
  234. .downcase
  235. .gsub(/[^a-z0-9\s]/, '') # Remove special characters
  236. .gsub(/\s+/, '_') # Replace spaces with underscores
  237. .gsub(/^_+|_+$/, '') # Remove leading/trailing underscores
  238. .gsub(/__+/, '_') # Replace multiple underscores with single
  239. # Limit length
  240. 10 base_key = base_key[0..30] if base_key.length > 30
  241. # Handle duplicates
  242. 10 final_key = base_key
  243. 10 counter = 2
  244. 10 all_strings = @strings_data.merge(@extracted_strings)
  245. 10 while all_strings.key?(final_key)
  246. final_key = "#{base_key}_#{counter}"
  247. counter += 1
  248. end
  249. 10 final_key
  250. end
  251. # Update strings.xml file for a specific language
  252. 1 def update_strings_xml(lang_dir)
  253. 4 Core::Logger.debug "Updating strings.xml for #{lang_dir}..."
  254. 4 res_dir = File.join(@source_path, @config['source_directory'] || 'src/main', 'res', lang_dir)
  255. 4 FileUtils.mkdir_p(res_dir)
  256. 4 strings_xml_file = File.join(res_dir, 'strings.xml')
  257. 4 Core::Logger.debug "Strings.xml path: #{strings_xml_file}"
  258. # Load existing strings.xml or create new
  259. 4 doc = if File.exist?(strings_xml_file)
  260. Core::Logger.debug "Loading existing strings.xml..."
  261. REXML::Document.new(File.read(strings_xml_file))
  262. else
  263. 4 Core::Logger.debug "Creating new strings.xml..."
  264. 4 create_new_strings_xml
  265. end
  266. 4 resources = doc.root
  267. 4 Core::Logger.debug "Processing #{@strings_data.keys.length} files..."
  268. # Build a hash of existing strings for faster lookup
  269. 4 existing_strings = {}
  270. 4 resources.elements.each('string') do |elem|
  271. name = elem.attributes['name']
  272. existing_strings[name] = elem if name
  273. end
  274. 4 Core::Logger.debug "Found #{existing_strings.keys.length} existing strings"
  275. # Add new strings from strings.json (now structured by file)
  276. 4 @strings_data.each do |file_prefix, file_strings|
  277. 4 next unless file_strings.is_a?(Hash)
  278. 4 Core::Logger.debug "Processing #{file_prefix} with #{file_strings.keys.length} strings..."
  279. 4 file_strings.each do |key, value|
  280. # Create full key with file prefix
  281. 4 full_key = "#{file_prefix}_#{key}"
  282. # Check if string already exists (using hash lookup - much faster)
  283. 4 unless existing_strings[full_key]
  284. # Add new string element
  285. 4 string_elem = REXML::Element.new('string')
  286. 4 string_elem.add_attribute('name', full_key)
  287. # Use translated value if available for this language
  288. 4 translated_value = get_translated_value(full_key, value, lang_dir)
  289. # Trim whitespace and normalize the string for XML
  290. 4 normalized_value = translated_value.strip.gsub(/\s+/, ' ')
  291. # Escape apostrophes for Android XML strings
  292. 4 normalized_value = normalized_value.gsub("'", "\\'")
  293. # Don't let REXML auto-escape, we'll do it manually
  294. 4 string_elem.text = normalized_value
  295. 4 resources.add_element(string_elem)
  296. 4 Core::Logger.debug "Added string '#{full_key}' to #{lang_dir}/strings.xml"
  297. end
  298. end
  299. end
  300. # Write updated XML with custom formatting to prevent multiline strings
  301. 4 File.open(strings_xml_file, 'w') do |file|
  302. # Use a custom formatter that doesn't wrap text content
  303. 4 formatter = REXML::Formatters::Pretty.new(4)
  304. 4 formatter.compact = true # Don't add extra whitespace inside text
  305. 4 formatter.write(doc, file)
  306. end
  307. 4 Core::Logger.info "Updated #{lang_dir}/strings.xml"
  308. end
  309. # Create a new strings.xml document
  310. 1 def create_new_strings_xml
  311. 6 doc = REXML::Document.new
  312. 6 doc.add(REXML::XMLDecl.new('1.0', 'utf-8'))
  313. 6 resources = REXML::Element.new('resources')
  314. 6 doc.add_element(resources)
  315. 6 doc
  316. end
  317. # Get translated value for a specific language
  318. 1 def get_translated_value(key, default_value, lang_dir)
  319. # For now, return the default value
  320. # In the future, this could load translations from a separate file
  321. 5 default_value
  322. end
  323. # Generate Kotlin code for StringManager
  324. 1 def generate_string_manager_kotlin
  325. return unless @config['resource_manager_directory']
  326. resource_manager_dir = File.join(@source_path, @config['source_directory'] || 'src/main',
  327. 'java/com/kotlinjsonui/generated')
  328. FileUtils.mkdir_p(resource_manager_dir)
  329. output_file = File.join(resource_manager_dir, 'StringManager.kt')
  330. kotlin_code = generate_kotlin_code(@strings_data)
  331. File.write(output_file, kotlin_code)
  332. Core::Logger.info "✓ Generated StringManager.kt"
  333. end
  334. 1 def generate_kotlin_code(strings)
  335. timestamp = Time.now.strftime('%Y-%m-%d %H:%M:%S')
  336. code = []
  337. code << "// StringManager.kt"
  338. code << "// Auto-generated file - DO NOT EDIT"
  339. code << "// Generated at: #{timestamp}"
  340. code << ""
  341. code << "package com.kotlinjsonui.generated"
  342. code << ""
  343. code << "import android.content.Context"
  344. code << ""
  345. code << "object StringManager {"
  346. code << " // String resource IDs mapped from strings.json keys"
  347. code << " private val stringResources: Map<String, Int> = mapOf("
  348. # Add string resource mappings
  349. strings.keys.sort.each do |key|
  350. code << " \"#{key}\" to R.string.#{key},"
  351. end
  352. # Remove trailing comma from last item
  353. if strings.any?
  354. code[-1] = code[-1].chomp(',')
  355. end
  356. code << " )"
  357. code << ""
  358. code << " // Get localized string by key"
  359. code << " fun getString(context: Context, key: String): String {"
  360. code << " val resId = stringResources[key]"
  361. code << " return if (resId != null) {"
  362. code << " context.getString(resId)"
  363. code << " } else {"
  364. code << " // Fallback to key itself if not found"
  365. code << " println(\"Warning: String key '$key' not found in strings.json\")"
  366. code << " key"
  367. code << " }"
  368. code << " }"
  369. code << ""
  370. code << " // Extension function for easy access"
  371. code << " fun String.localized(context: Context): String {"
  372. code << " // Check if this is a string key (snake_case)"
  373. code << " return if (this.matches(Regex(\"^[a-z]+(_[a-z]+)*$\"))) {"
  374. code << " getString(context, this)"
  375. code << " } else {"
  376. code << " // Return as-is if not a key"
  377. code << " this"
  378. code << " }"
  379. code << " }"
  380. code << ""
  381. # Generate static accessors for each string
  382. strings.keys.sort.each do |key|
  383. property_name = snake_to_camel(key)
  384. code << " // Access string: #{key}"
  385. code << " fun get#{property_name.capitalize}(context: Context): String ="
  386. code << " getString(context, \"#{key}\")"
  387. code << ""
  388. end
  389. code << "}"
  390. code.join("\n")
  391. end
  392. 1 def snake_to_camel(snake_case)
  393. 3 parts = snake_case.split('_')
  394. 3 first_part = parts.shift
  395. 3 camel = first_part + parts.map(&:capitalize).join
  396. 3 camel
  397. end
  398. end
  399. end
  400. end
  401. end

lib/core/resources_manager.rb

100.0% lines covered

45 relevant lines. 45 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'json'
  3. 1 require 'fileutils'
  4. 1 require_relative 'config_manager'
  5. 1 require_relative 'project_finder'
  6. 1 require_relative 'logger'
  7. 1 require_relative 'resources/string_manager'
  8. 1 require_relative 'resources/color_manager'
  9. 1 module KjuiTools
  10. 1 module Core
  11. 1 class ResourcesManager
  12. 1 def initialize(config, source_path)
  13. 17 @config = config
  14. 17 @source_path = source_path
  15. 17 @layouts_dir = File.join(@source_path, @config['source_directory'] || 'src/main', 'assets/Layouts')
  16. 17 @resources_dir = File.join(@layouts_dir, 'Resources')
  17. 17 @string_manager = Resources::StringManager.new(@config, @source_path, @resources_dir)
  18. 17 @color_manager = Resources::ColorManager.new(@config, @source_path, @resources_dir)
  19. end
  20. # Main method called from build command
  21. 1 def extract_resources(json_files)
  22. # Extract resources from JSON files
  23. 10 extract_from_json_files(json_files)
  24. # Apply extracted strings to strings.xml files
  25. 10 apply_extracted_strings
  26. # Apply extracted colors
  27. 10 apply_extracted_colors
  28. end
  29. # Extract resources from JSON files
  30. 1 def extract_from_json_files(json_files)
  31. 13 processed_files = []
  32. 13 processed_count = 0
  33. 13 skipped_count = 0
  34. 13 json_files.each do |json_file|
  35. # Skip files in Resources directory only
  36. 11 if json_file.include?('/Resources/')
  37. 2 skipped_count += 1
  38. 2 next
  39. end
  40. 9 processed_files << json_file
  41. 9 processed_count += 1
  42. end
  43. 13 if processed_count == 0
  44. 4 Logger.info "No files to process for resource extraction"
  45. 4 return
  46. end
  47. 9 Logger.info "Extracting resources from #{processed_count} files (#{skipped_count} skipped)..."
  48. # Ensure Resources directory exists
  49. 9 FileUtils.mkdir_p(@resources_dir)
  50. # Process strings through StringManager
  51. 9 @string_manager.process_strings(processed_files, processed_count, skipped_count)
  52. # Process colors through ColorManager
  53. 9 @color_manager.process_colors(processed_files, processed_count, skipped_count, @config)
  54. end
  55. 1 private
  56. 1 def apply_extracted_strings
  57. 10 Logger.info "Applying extracted strings to strings.xml files..."
  58. 10 @string_manager.apply_to_strings_files
  59. end
  60. 1 def apply_extracted_colors
  61. 10 Logger.info "Applying extracted colors..."
  62. 10 @color_manager.apply_to_color_assets
  63. end
  64. end
  65. end
  66. end

lib/core/style_loader.rb

100.0% lines covered

35 relevant lines. 35 lines covered and 0 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 require 'json'
  3. 1 class StyleLoader
  4. 1 def initialize(config)
  5. 39 @config = config
  6. 39 @styles = {}
  7. 39 load_styles
  8. end
  9. 1 def apply_styles(json_data)
  10. 13 apply_styles_recursive(json_data)
  11. 13 json_data
  12. end
  13. 1 private
  14. 1 def load_styles
  15. 39 project_path = @config['project_path'] || Dir.pwd
  16. 39 styles_dir = File.join(project_path, 'src', 'main', 'assets', 'Styles')
  17. 39 styles_dir = File.join(project_path, 'app', 'src', 'main', 'assets', 'Styles') unless Dir.exist?(styles_dir)
  18. 39 return unless Dir.exist?(styles_dir)
  19. 14 Dir.glob(File.join(styles_dir, '*.json')).each do |style_file|
  20. 25 style_name = File.basename(style_file, '.json')
  21. begin
  22. 25 style_content = File.read(style_file)
  23. 25 @styles[style_name] = JSON.parse(style_content)
  24. rescue => e
  25. 1 puts "Warning: Failed to load style #{style_name}: #{e.message}"
  26. end
  27. end
  28. end
  29. 1 def apply_styles_recursive(element)
  30. 18 return unless element.is_a?(Hash)
  31. # Apply style if present
  32. 17 if element['style']
  33. 8 style_names = element['style'].is_a?(Array) ? element['style'] : [element['style']]
  34. 8 style_names.each do |style_name|
  35. 9 if @styles[style_name]
  36. # Merge style attributes (style attributes are overridden by inline attributes)
  37. 8 @styles[style_name].each do |key, value|
  38. 15 element[key] = value unless element.key?(key)
  39. end
  40. end
  41. end
  42. # Remove style attribute after applying
  43. 8 element.delete('style')
  44. end
  45. # Apply recursively to children
  46. 17 if element['children']
  47. 2 element['children'].each { |child| apply_styles_recursive(child) }
  48. 16 elsif element['child']
  49. 5 if element['child'].is_a?(Array)
  50. 7 element['child'].each { |child| apply_styles_recursive(child) }
  51. else
  52. 1 apply_styles_recursive(element['child'])
  53. end
  54. end
  55. end
  56. end

lib/core/type_converter.rb

64.98% lines covered

237 relevant lines. 154 lines covered and 83 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'json'
  3. 1 require_relative 'config_manager'
  4. 1 module KjuiTools
  5. 1 module Core
  6. # Converts JSON primitive types to Kotlin types
  7. # This ensures cross-platform compatibility with SwiftJsonUI and ReactJsonUI
  8. 1 class TypeConverter
  9. # Cache for colors.json data
  10. 1 @colors_data = nil
  11. 1 @colors_file_path = nil
  12. 1 class << self
  13. 1 attr_accessor :colors_data, :colors_file_path
  14. # Load colors.json from the specified path or auto-detect from project config
  15. # @param path [String, nil] optional path to colors.json
  16. # @return [Hash] the colors data
  17. 1 def load_colors_json(path = nil)
  18. return @colors_data if @colors_data && (@colors_file_path == path || path.nil?)
  19. if path
  20. @colors_file_path = path
  21. else
  22. # Use ConfigManager to get correct path
  23. config = ConfigManager.load_config
  24. config_dir = config['_config_dir'] || Dir.pwd
  25. source_dir = config['source_directory'] || 'app/src/main'
  26. layouts_dir = config['layouts_directory'] || 'assets/Layouts'
  27. resources_path = File.join(config_dir, source_dir, layouts_dir, 'Resources', 'colors.json')
  28. @colors_file_path = resources_path
  29. end
  30. if @colors_file_path && File.exist?(@colors_file_path)
  31. begin
  32. @colors_data = JSON.parse(File.read(@colors_file_path))
  33. rescue JSON::ParserError => e
  34. warn "[TypeConverter] Warning: Failed to parse colors.json: #{e.message}"
  35. @colors_data = {}
  36. end
  37. else
  38. @colors_data = {}
  39. end
  40. @colors_data
  41. end
  42. # Check if a color name exists in colors.json
  43. # @param color_name [String] the color name to check
  44. # @return [Boolean] true if the color exists
  45. 1 def color_exists?(color_name)
  46. load_colors_json
  47. @colors_data.key?(color_name)
  48. end
  49. # Get hex value for a color name from colors.json
  50. # @param color_name [String] the color name
  51. # @return [String, nil] the hex value or nil if not found
  52. 1 def get_color_hex(color_name)
  53. load_colors_json
  54. @colors_data[color_name]
  55. end
  56. # Clear the cached colors data (useful for testing)
  57. 1 def clear_colors_cache
  58. @colors_data = nil
  59. @colors_file_path = nil
  60. end
  61. end
  62. # Language key for this platform
  63. 1 LANGUAGE = 'kotlin'
  64. # Available modes for this platform
  65. 1 MODES = %w[compose xml].freeze
  66. # JSON type -> Kotlin type mapping (common types)
  67. 1 TYPE_MAPPING = {
  68. # Standard types (cross-platform)
  69. 'String' => 'String',
  70. 'string' => 'String',
  71. 'Int' => 'Int',
  72. 'int' => 'Int',
  73. 'Integer' => 'Int',
  74. 'integer' => 'Int',
  75. 'Double' => 'Double',
  76. 'double' => 'Double',
  77. 'Float' => 'Float',
  78. 'float' => 'Float',
  79. 'Bool' => 'Boolean',
  80. 'bool' => 'Boolean',
  81. 'Boolean' => 'Boolean',
  82. 'boolean' => 'Boolean',
  83. # iOS-specific types mapped to Kotlin equivalents
  84. 'CGFloat' => 'Float',
  85. 'Void' => 'Unit',
  86. 'void' => 'Unit',
  87. # Kotlin/Compose-specific types
  88. 'Dp' => 'Dp',
  89. 'Alignment' => 'Alignment',
  90. # Collection types
  91. 'CollectionDataSource' => 'CollectionDataSource'
  92. }.freeze
  93. # Mode-specific type mapping (types that differ between compose and xml)
  94. MODE_TYPE_MAPPING = {
  95. 1 'Color' => { 'compose' => 'Color', 'xml' => 'Int' },
  96. 'color' => { 'compose' => 'Color', 'xml' => 'Int' },
  97. 'Image' => { 'compose' => 'Painter', 'xml' => 'Drawable' },
  98. 'image' => { 'compose' => 'Painter', 'xml' => 'Drawable' }
  99. }.freeze
  100. # Default values for each Kotlin type
  101. 1 DEFAULT_VALUES = {
  102. 'String' => '""',
  103. 'Int' => '0',
  104. 'Double' => '0.0',
  105. 'Float' => '0f',
  106. 'Boolean' => 'false',
  107. 'Color' => 'Color.Unspecified',
  108. 'Dp' => '0.dp',
  109. 'Alignment' => 'Alignment.TopStart',
  110. 'Painter' => 'EmptyPainter()',
  111. 'Drawable' => 'null',
  112. 'CollectionDataSource' => 'CollectionDataSource()'
  113. }.freeze
  114. 1 class << self
  115. # Extract platform-specific value from a potentially nested hash
  116. # Supports three formats:
  117. # 1. Simple value: "String" -> "String"
  118. # 2. Language only: { "swift": "Int", "kotlin": "Int" } -> "Int"
  119. # 3. Language + mode: { "kotlin": { "compose": "Color", "xml": "Int" } } -> "Color" or "Int"
  120. #
  121. # @param value [Object] the value (String, Hash, or other)
  122. # @param mode [String] the mode (compose, xml)
  123. # @return [Object] the extracted value for this platform/mode
  124. 1 def extract_platform_value(value, mode = nil)
  125. 33 return value unless value.is_a?(Hash)
  126. # Try to get language-specific value
  127. 9 lang_value = value[LANGUAGE]
  128. 9 return value unless lang_value # No language key found, return original hash
  129. # If language value is a hash, try to get mode-specific value
  130. 8 if lang_value.is_a?(Hash) && mode
  131. 7 mode_value = lang_value[mode]
  132. 7 return mode_value if mode_value
  133. # Fallback: try first available mode
  134. 1 MODES.each do |m|
  135. 1 return lang_value[m] if lang_value[m]
  136. end
  137. # No mode found, return the hash as-is (might be a custom structure)
  138. lang_value
  139. else
  140. # Language value is not a hash, return it directly
  141. 1 lang_value
  142. end
  143. end
  144. # Convert JSON type to Kotlin type
  145. # @param json_type [String] the type specified in JSON
  146. # @param mode [String] the mode (compose, xml) for mode-specific types
  147. # @return [String] the corresponding Kotlin type
  148. 1 def to_kotlin_type(json_type, mode = nil)
  149. 74 return json_type if json_type.nil? || json_type.to_s.empty?
  150. 72 type_str = json_type.to_s.strip
  151. # Check for optional type suffix
  152. 72 is_optional = type_str.end_with?('?')
  153. 72 base_type = is_optional ? type_str[0...-1] : type_str
  154. # Check for Array(ElementType) syntax -> List<ElementType>
  155. 72 if (match = base_type.match(/^Array\((.+)\)$/))
  156. 4 element_type = to_kotlin_type(match[1].strip, mode)
  157. 4 result = "List<#{element_type}>"
  158. 4 return is_optional ? "#{result}?" : result
  159. end
  160. # Check for Dictionary(KeyType,ValueType) syntax -> Map<KeyType, ValueType>
  161. 68 if (match = base_type.match(/^Dictionary\((.+),\s*(.+)\)$/))
  162. 3 key_type = to_kotlin_type(match[1].strip, mode)
  163. 3 value_type = to_kotlin_type(match[2].strip, mode)
  164. 3 result = "Map<#{key_type}, #{value_type}>"
  165. 3 return is_optional ? "#{result}?" : result
  166. end
  167. # Check for function type: (params) -> ReturnType or ((params) -> ReturnType)?
  168. 65 func_result = parse_function_type(type_str, mode)
  169. 65 return func_result if func_result
  170. # Check mode-specific mapping first
  171. 52 if mode && MODE_TYPE_MAPPING.key?(base_type)
  172. 8 result = MODE_TYPE_MAPPING[base_type][mode] || MODE_TYPE_MAPPING[base_type]['compose']
  173. 8 return is_optional ? "#{result}?" : result
  174. end
  175. # Then check common mapping, or return as-is if not found
  176. 44 result = TYPE_MAPPING[base_type] || base_type
  177. 44 is_optional ? "#{result}?" : result
  178. end
  179. # Parse a function type string and convert to Kotlin
  180. # Handles: (Int) -> Void, ((Image) -> Color), (() -> Unit)?, etc.
  181. # All function types are converted to optional by default (for callbacks)
  182. # @param type_str [String] the type string to parse
  183. # @param mode [String] the mode (compose, xml)
  184. # @return [String, nil] the Kotlin function type or nil if not a function type
  185. 1 def parse_function_type(type_str, mode = nil)
  186. 65 working_str = type_str.strip
  187. # Check for optional wrapper: ((...) -> ...)? or (() -> ...)?
  188. 65 if working_str.end_with?(')?')
  189. 3 if working_str.start_with?('(')
  190. 3 inner = extract_balanced_content(working_str[1...-2], '(', ')')
  191. 3 if inner && inner == working_str[1...-2]
  192. 3 working_str = working_str[1...-2]
  193. end
  194. end
  195. # Check for grouping parentheses: ((params) -> ReturnType) without ?
  196. 62 elsif working_str.start_with?('(') && working_str.end_with?(')')
  197. 6 inner = working_str[1...-1]
  198. 6 if find_arrow_position(inner)
  199. 6 working_str = inner
  200. end
  201. end
  202. # Now try to parse as function: (params) -> ReturnType
  203. 65 arrow_pos = find_arrow_position(working_str)
  204. 65 return nil unless arrow_pos
  205. 13 params_part = working_str[0...arrow_pos].strip
  206. 13 return_part = working_str[(arrow_pos + 2)..].strip
  207. # params_part should be (...)
  208. 13 return nil unless params_part.start_with?('(') && params_part.end_with?(')')
  209. 13 params_inner = params_part[1...-1].strip
  210. # Parse parameters (handling nested types)
  211. 13 converted_params = parse_parameter_list_no_optional(params_inner, mode)
  212. # Convert return type (Void -> Unit)
  213. 13 converted_return = convert_single_type(return_part, mode)
  214. # Build result - all function types become optional (for callbacks)
  215. 13 "((#{converted_params}) -> #{converted_return})?"
  216. end
  217. # Convert a single type without making it optional
  218. 1 def convert_single_type(type_str, mode = nil)
  219. 25 return type_str if type_str.nil? || type_str.to_s.empty?
  220. 25 str = type_str.to_s.strip
  221. 25 is_optional = str.end_with?('?')
  222. 25 base = is_optional ? str[0...-1] : str
  223. # Check mode-specific mapping first
  224. 25 if mode && MODE_TYPE_MAPPING.key?(base)
  225. 2 result = MODE_TYPE_MAPPING[base][mode] || MODE_TYPE_MAPPING[base]['compose']
  226. 2 return is_optional ? "#{result}?" : result
  227. end
  228. 23 result = TYPE_MAPPING[base] || base
  229. 23 is_optional ? "#{result}?" : result
  230. end
  231. # Parse parameter list without making types optional
  232. 1 def parse_parameter_list_no_optional(params_str, mode = nil)
  233. 13 return '' if params_str.nil? || params_str.empty?
  234. 10 params = split_parameters(params_str)
  235. 22 params.map { |p| convert_single_type(p.strip, mode) }.join(', ')
  236. end
  237. # Find the position of the arrow (->) that separates params from return type
  238. 1 def find_arrow_position(str)
  239. 71 depth = 0
  240. 71 i = 0
  241. 71 while i < str.length
  242. 504 char = str[i]
  243. 504 if char == '('
  244. 20 depth += 1
  245. 484 elsif char == ')'
  246. 20 depth -= 1
  247. 464 elsif char == '-' && str[i + 1] == '>' && depth == 0
  248. 19 return i
  249. end
  250. 485 i += 1
  251. end
  252. nil
  253. end
  254. # Split parameters by comma, respecting nested parentheses and generics
  255. 1 def split_parameters(str)
  256. 10 return [] if str.nil? || str.empty?
  257. 10 params = []
  258. 10 current = ''
  259. 10 depth = 0
  260. 10 str.each_char do |char|
  261. 85 if char == '(' || char == '<' || char == '['
  262. 1 depth += 1
  263. 1 current += char
  264. 84 elsif char == ')' || char == '>' || char == ']'
  265. 2 depth -= 1
  266. 2 current += char
  267. 82 elsif char == ',' && depth == 0
  268. 2 params << current.strip unless current.strip.empty?
  269. 2 current = ''
  270. else
  271. 80 current += char
  272. end
  273. end
  274. 10 params << current.strip unless current.strip.empty?
  275. 10 params
  276. end
  277. # Extract balanced content
  278. 1 def extract_balanced_content(str, open_char, close_char)
  279. 3 depth = 0
  280. 3 str.each_char do |char|
  281. 47 depth += 1 if char == open_char
  282. 47 depth -= 1 if char == close_char
  283. 47 return nil if depth < 0
  284. end
  285. 3 depth == 0 ? str : nil
  286. end
  287. # Check if the type is a primitive type
  288. # @param json_type [String] the type to check
  289. # @return [Boolean] true if it's a primitive type
  290. 1 def primitive?(json_type)
  291. 9 return false if json_type.nil? || json_type.to_s.empty?
  292. 7 TYPE_MAPPING.key?(json_type.to_s)
  293. end
  294. # Get default value for a Kotlin type
  295. # @param kotlin_type [String] the Kotlin type
  296. # @return [String] the default value as Kotlin code
  297. 1 def default_value(kotlin_type)
  298. 9 DEFAULT_VALUES[kotlin_type] || 'null'
  299. end
  300. # Format a value for Kotlin code based on type
  301. # @param value [Object] the value to format
  302. # @param kotlin_type [String] the Kotlin type
  303. # @return [String] the formatted value as Kotlin code
  304. 1 def format_value(value, kotlin_type)
  305. return 'null' if value.nil?
  306. case kotlin_type
  307. when 'String'
  308. format_string_value(value)
  309. when 'Int'
  310. value.to_i.to_s
  311. when 'Double'
  312. "#{value.to_f}"
  313. when 'Float'
  314. "#{value.to_f}f"
  315. when 'Boolean'
  316. value.to_s.downcase
  317. when 'Color'
  318. format_color_value(value)
  319. else
  320. value.to_s
  321. end
  322. end
  323. # Convert data property from JSON format to normalized format
  324. # @param data_prop [Hash] the data property from JSON
  325. # @param mode [String] the mode (compose, xml)
  326. # @return [Hash] normalized data property with Kotlin type
  327. 1 def normalize_data_property(data_prop, mode = nil)
  328. 15 return data_prop unless data_prop.is_a?(Hash)
  329. 15 normalized = data_prop.dup
  330. # Extract platform-specific class
  331. 15 raw_class = nil
  332. 15 if normalized['class']
  333. 15 raw_class = extract_platform_value(normalized['class'], mode)
  334. 15 normalized['class'] = to_kotlin_type(raw_class, mode)
  335. end
  336. # Extract platform-specific defaultValue and convert for special types
  337. 15 if normalized['defaultValue']
  338. 11 raw_value = extract_platform_value(normalized['defaultValue'], mode)
  339. 11 normalized['defaultValue'] = convert_default_value(raw_value, raw_class, mode)
  340. end
  341. 15 normalized
  342. end
  343. # Convert defaultValue based on the type
  344. # For Color: convert hex/color name to platform-specific format
  345. # For Image: convert image name to platform-specific format
  346. # @param value [Object] the raw default value
  347. # @param raw_class [String] the original class type from JSON
  348. # @param mode [String] the mode (compose, xml)
  349. # @return [Object] the converted default value
  350. 1 def convert_default_value(value, raw_class, mode = nil)
  351. 11 return value unless value.is_a?(String) && raw_class.is_a?(String)
  352. 10 base_class = raw_class.end_with?('?') ? raw_class[0...-1] : raw_class
  353. 10 case base_class.downcase
  354. when 'color'
  355. 3 convert_color_default_value(value, mode)
  356. when 'image'
  357. convert_image_default_value(value, mode)
  358. else
  359. 7 value
  360. end
  361. end
  362. # Convert color value (hex or color name) to Kotlin Color
  363. # @param value [String] hex string (#RRGGBB or #RRGGBBAA) or color name
  364. # @param mode [String] the mode (compose, xml)
  365. # @return [String] Kotlin color code
  366. 1 def convert_color_default_value(value, mode = nil)
  367. # Already formatted as Kotlin code
  368. 3 return value if value.start_with?('Color') || value.start_with?('0x') || value.start_with?('0X')
  369. if value.start_with?('#')
  370. # Hex color
  371. hex = value.sub('#', '')
  372. if mode == 'xml'
  373. # For XML, use Int format
  374. if hex.length == 6
  375. "0xFF#{hex.upcase}"
  376. elsif hex.length == 8
  377. "0x#{hex.upcase}"
  378. else
  379. "0"
  380. end
  381. else
  382. # For Compose, use Color()
  383. if hex.length == 6
  384. "Color(0xFF#{hex.upcase})"
  385. elsif hex.length == 8
  386. "Color(0x#{hex.upcase})"
  387. else
  388. "Color.Unspecified"
  389. end
  390. end
  391. else
  392. # Color name from colors.json (e.g., "medium_gray", "deep_blue")
  393. # Get hex value from colors.json and convert to Color()
  394. hex_value = get_color_hex(value)
  395. if hex_value
  396. hex = hex_value.sub('#', '')
  397. if mode == 'xml'
  398. if hex.length == 6
  399. "0xFF#{hex.upcase}"
  400. elsif hex.length == 8
  401. "0x#{hex.upcase}"
  402. else
  403. "0"
  404. end
  405. else
  406. # For Compose, use Color() with hex value from colors.json
  407. if hex.length == 6
  408. "Color(0xFF#{hex.upcase})"
  409. elsif hex.length == 8
  410. "Color(0x#{hex.upcase})"
  411. else
  412. "Color.Unspecified"
  413. end
  414. end
  415. else
  416. warn "[TypeConverter] Warning: Color '#{value}' is not defined in colors.json"
  417. if mode == 'xml'
  418. "0"
  419. else
  420. "Color.Unspecified"
  421. end
  422. end
  423. end
  424. end
  425. # Convert image name to Kotlin Painter/Drawable
  426. # @param value [String] image name
  427. # @param mode [String] the mode (compose, xml)
  428. # @return [String] Kotlin image code
  429. 1 def convert_image_default_value(value, mode = nil)
  430. # Already formatted as Kotlin code
  431. return value if value.start_with?('painterResource') || value.start_with?('R.')
  432. if mode == 'xml'
  433. # For XML, reference drawable resource
  434. "R.drawable.#{value}"
  435. else
  436. # For Compose, use painterResource
  437. "painterResource(R.drawable.#{value})"
  438. end
  439. end
  440. # Convert array of data properties
  441. # @param data_props [Array<Hash>] array of data properties
  442. # @param mode [String] the mode (compose, xml)
  443. # @return [Array<Hash>] normalized data properties
  444. 1 def normalize_data_properties(data_props, mode = nil)
  445. 3 return [] unless data_props.is_a?(Array)
  446. 4 data_props.map { |prop| normalize_data_property(prop, mode) }
  447. end
  448. 1 private
  449. 1 def format_string_value(value)
  450. str = value.to_s
  451. # Handle already quoted strings
  452. if str.start_with?('"') && str.end_with?('"')
  453. str
  454. elsif str.start_with?("'") && str.end_with?("'")
  455. # Convert single quotes to double quotes
  456. inner = str[1..-2]
  457. "\"#{escape_string(inner)}\""
  458. else
  459. "\"#{escape_string(str)}\""
  460. end
  461. end
  462. 1 def escape_string(str)
  463. str.gsub('\\', '\\\\').gsub('"', '\\"')
  464. end
  465. 1 def format_color_value(value)
  466. if value.is_a?(String) && value.start_with?('#')
  467. hex = value.sub('#', '')
  468. if hex.length == 6
  469. "Color(0xFF#{hex.upcase})"
  470. elsif hex.length == 8
  471. "Color(0x#{hex.upcase})"
  472. else
  473. "Color.Unspecified"
  474. end
  475. else
  476. value.to_s
  477. end
  478. end
  479. end
  480. end
  481. end
  482. end

lib/hotloader/ip_monitor.rb

84.78% lines covered

92 relevant lines. 78 lines covered and 14 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'socket'
  3. 1 require 'json'
  4. 1 require 'fileutils'
  5. 1 module KjuiTools
  6. 1 module Hotloader
  7. 1 class IpMonitor
  8. 1 CONFIG_FILE = 'kjui.config.json'
  9. 1 CHECK_INTERVAL = 5 # seconds
  10. 1 def initialize(project_root = nil)
  11. 14 @project_root = project_root || find_project_root
  12. 14 @config_path = File.join(@project_root, CONFIG_FILE)
  13. 14 @running = false
  14. 14 @thread = nil
  15. 14 @last_ip = nil
  16. end
  17. 1 def start
  18. 4 return if @running
  19. 3 @running = true
  20. 3 @thread = Thread.new do
  21. 3 while @running
  22. check_and_update_ip
  23. sleep CHECK_INTERVAL
  24. end
  25. end
  26. 3 puts "IP Monitor started"
  27. end
  28. 1 def stop
  29. 3 @running = false
  30. 3 @thread&.join
  31. 3 puts "IP Monitor stopped"
  32. end
  33. 1 private
  34. 1 def find_project_root(start_path = Dir.pwd)
  35. 3 current = start_path
  36. # First check current and parent directories
  37. 3 while current != '/'
  38. 8 if File.exist?(File.join(current, CONFIG_FILE))
  39. 1 return current
  40. end
  41. # Check subdirectories for kjui.config.json
  42. 7 Dir.glob(File.join(current, '*', CONFIG_FILE)).each do |config_path|
  43. 1 if File.exist?(config_path)
  44. 1 return File.dirname(config_path)
  45. end
  46. end
  47. 6 current = File.dirname(current)
  48. end
  49. 1 Dir.pwd
  50. end
  51. 1 def check_and_update_ip
  52. current_ip = get_local_ip
  53. if current_ip && current_ip != @last_ip
  54. update_config(current_ip)
  55. update_android_configs(current_ip)
  56. @last_ip = current_ip
  57. puts "IP updated to: #{current_ip}"
  58. end
  59. rescue => e
  60. puts "Error checking IP: #{e.message}"
  61. end
  62. 1 def get_local_ip
  63. # Try to get WiFi IP first (common interface names)
  64. 1 interfaces = ['wlan0', 'wlp2s0', 'wlp3s0', 'en0', 'en1', 'eth0', 'eth1']
  65. 1 interfaces.each do |interface|
  66. 4 ip = get_interface_ip(interface)
  67. 4 return ip if ip && !ip.start_with?('127.')
  68. end
  69. # Fallback: get any non-localhost IP
  70. Socket.ip_address_list.each do |addr|
  71. if addr.ipv4? && !addr.ipv4_loopback? && !addr.ipv4_multicast?
  72. return addr.ip_address
  73. end
  74. end
  75. nil
  76. end
  77. 1 def get_interface_ip(interface)
  78. 4 Socket.getifaddrs.each do |ifaddr|
  79. 152 if ifaddr.name == interface && ifaddr.addr&.ipv4?
  80. 1 return ifaddr.addr.ip_address
  81. end
  82. end
  83. nil
  84. rescue
  85. nil
  86. end
  87. 1 def update_config(ip)
  88. 2 config = if File.exist?(@config_path)
  89. 1 JSON.parse(File.read(@config_path))
  90. else
  91. 1 {}
  92. end
  93. 2 config['hotloader'] ||= {}
  94. 2 config['hotloader']['ip'] = ip
  95. 2 config['hotloader']['port'] ||= 8081
  96. 2 config['hotloader']['enabled'] = true
  97. 2 File.write(@config_path, JSON.pretty_generate(config))
  98. end
  99. 1 def update_android_configs(ip)
  100. # Load config to get port
  101. 1 config = if File.exist?(@config_path)
  102. JSON.parse(File.read(@config_path))
  103. else
  104. 1 {}
  105. end
  106. 1 port = config.dig('hotloader', 'port') || 8081
  107. # Update local.properties if it exists
  108. 1 local_props = File.join(@project_root, 'local.properties')
  109. 1 if File.exist?(local_props)
  110. 1 content = File.read(local_props)
  111. # Remove old hotloader.ip line if exists
  112. 1 content.gsub!(/^hotloader\.ip=.*$/, '')
  113. 1 content.gsub!(/^hotloader\.port=.*$/, '')
  114. # Add new lines
  115. 1 content += "\nhotloader.ip=#{ip}"
  116. 1 content += "\nhotloader.port=#{port}"
  117. 1 File.write(local_props, content)
  118. end
  119. # Update any BuildConfig or resource files
  120. 1 update_build_config(ip)
  121. end
  122. 1 def update_build_config(ip)
  123. # Load config to get source directory and port
  124. 2 config = if File.exist?(@config_path)
  125. 1 JSON.parse(File.read(@config_path))
  126. else
  127. 1 {}
  128. end
  129. 2 source_dir = config['source_directory'] || 'src/main'
  130. 2 port = config.dig('hotloader', 'port') || 8081
  131. # Create or update hotloader config in assets
  132. 2 assets_dir = File.join(@project_root, source_dir, 'assets')
  133. 2 FileUtils.mkdir_p(assets_dir)
  134. 2 hotloader_config = File.join(assets_dir, 'hotloader.json')
  135. hotloader_json = {
  136. 2 'ip' => ip,
  137. 'port' => port,
  138. 'enabled' => true,
  139. 'websocket_endpoint' => "ws://#{ip}:#{port}",
  140. 'http_endpoint' => "http://#{ip}:#{port}"
  141. }
  142. 2 File.write(hotloader_config, JSON.pretty_generate(hotloader_json))
  143. end
  144. end
  145. end
  146. end

lib/xml/drawable/drawable_generator.rb

92.47% lines covered

93 relevant lines. 86 lines covered and 7 lines missed.
    
  1. 1 require 'digest'
  2. 1 require 'fileutils'
  3. 1 require_relative 'shape_drawable_generator'
  4. 1 require_relative 'ripple_drawable_generator'
  5. 1 require_relative 'state_list_drawable_generator'
  6. 1 require_relative 'drawable_hash_manager'
  7. 1 module DrawableGenerator
  8. 1 class Generator
  9. 1 def initialize(project_root)
  10. 59 @project_root = project_root
  11. # Check if we're already in a sample-app directory or need to look for one
  12. 59 if File.exist?(File.join(project_root, 'src', 'main', 'res'))
  13. 38 @drawable_dir = File.join(project_root, 'src', 'main', 'res', 'drawable')
  14. 21 elsif File.exist?(File.join(project_root, 'sample-app', 'src', 'main', 'res'))
  15. @drawable_dir = File.join(project_root, 'sample-app', 'src', 'main', 'res', 'drawable')
  16. 21 elsif File.exist?(File.join(project_root, 'app', 'src', 'main', 'res'))
  17. @drawable_dir = File.join(project_root, 'app', 'src', 'main', 'res', 'drawable')
  18. else
  19. # Default fallback
  20. 21 @drawable_dir = File.join(project_root, 'src', 'main', 'res', 'drawable')
  21. end
  22. 59 @hash_manager = DrawableHashManager.new(@drawable_dir)
  23. 59 @shape_generator = ShapeDrawableGenerator.new
  24. 59 @ripple_generator = RippleDrawableGenerator.new
  25. 59 @state_list_generator = StateListDrawableGenerator.new
  26. 59 ensure_drawable_directory
  27. end
  28. 1 def generate_for_component(json_data, component_type)
  29. 6 drawables = []
  30. # Check if we need a ripple effect drawable
  31. 6 if needs_ripple?(json_data, component_type)
  32. 4 drawable_name = generate_ripple_drawable(json_data, component_type)
  33. 4 drawables << drawable_name if drawable_name
  34. end
  35. # Check if we need a shape drawable
  36. 6 if needs_shape?(json_data)
  37. 3 drawable_name = generate_shape_drawable(json_data, component_type)
  38. 3 drawables << drawable_name if drawable_name
  39. end
  40. # Check if we need a state list drawable
  41. 6 if needs_state_list?(json_data)
  42. drawable_name = generate_state_list_drawable(json_data, component_type)
  43. drawables << drawable_name if drawable_name
  44. end
  45. 6 drawables.first # Return the primary drawable (usually state list or ripple)
  46. end
  47. 1 def get_background_drawable(json_data, component_type)
  48. 5 return nil unless json_data
  49. # Priority order: state list > ripple > shape > color
  50. 4 if needs_state_list?(json_data)
  51. 1 generate_state_list_drawable(json_data, component_type)
  52. 3 elsif needs_ripple?(json_data, component_type)
  53. 1 generate_ripple_drawable(json_data, component_type)
  54. 2 elsif needs_shape?(json_data)
  55. 1 generate_shape_drawable(json_data, component_type)
  56. else
  57. nil
  58. end
  59. end
  60. 1 private
  61. 1 def ensure_drawable_directory
  62. 59 FileUtils.mkdir_p(@drawable_dir) unless Dir.exist?(@drawable_dir)
  63. end
  64. 1 def needs_ripple?(json_data, component_type)
  65. 17 return false unless json_data
  66. # Check for click handlers
  67. 15 has_click_handler = json_data['onClick'] || json_data['onclick']
  68. # Certain component types should have ripple by default
  69. 15 clickable_components = ['Button', 'ImageButton', 'Card', 'ListItem']
  70. 15 is_clickable_component = clickable_components.include?(component_type)
  71. 15 has_click_handler || is_clickable_component
  72. end
  73. 1 def needs_shape?(json_data)
  74. 15 return false unless json_data
  75. # Check for shape-related attributes
  76. 13 json_data['cornerRadius'] ||
  77. json_data['borderWidth'] ||
  78. json_data['borderColor'] ||
  79. json_data['background']&.start_with?('#') ||
  80. json_data['gradient']
  81. end
  82. 1 def needs_state_list?(json_data)
  83. 17 return false unless json_data
  84. # Check for state-specific attributes
  85. 15 json_data['disabledBackground'] ||
  86. json_data['tapBackground'] ||
  87. json_data['selectedBackground'] ||
  88. json_data['pressedBackground'] ||
  89. json_data['focusedBackground']
  90. end
  91. 1 def generate_ripple_drawable(json_data, component_type)
  92. # Generate content based on attributes
  93. 5 drawable_content = @ripple_generator.generate(json_data, component_type)
  94. 5 return nil unless drawable_content
  95. # Generate hash-based filename
  96. 5 drawable_hash = @hash_manager.generate_hash(drawable_content)
  97. 5 drawable_name = "ripple_#{drawable_hash}"
  98. # Check if drawable already exists
  99. 5 if @hash_manager.drawable_exists?(drawable_name)
  100. return drawable_name
  101. end
  102. # Write the drawable file
  103. 5 drawable_path = File.join(@drawable_dir, "#{drawable_name}.xml")
  104. 5 File.write(drawable_path, drawable_content)
  105. 5 @hash_manager.register_drawable(drawable_name, drawable_content)
  106. 5 drawable_name
  107. end
  108. 1 def generate_shape_drawable(json_data, component_type)
  109. # Generate content based on attributes
  110. 5 drawable_content = @shape_generator.generate(json_data)
  111. 5 return nil unless drawable_content
  112. # Generate hash-based filename
  113. 5 drawable_hash = @hash_manager.generate_hash(drawable_content)
  114. 5 drawable_name = "shape_#{drawable_hash}"
  115. # Check if drawable already exists
  116. 5 if @hash_manager.drawable_exists?(drawable_name)
  117. return drawable_name
  118. end
  119. # Write the drawable file
  120. 5 drawable_path = File.join(@drawable_dir, "#{drawable_name}.xml")
  121. 5 File.write(drawable_path, drawable_content)
  122. 5 @hash_manager.register_drawable(drawable_name, drawable_content)
  123. 5 drawable_name
  124. end
  125. 1 def generate_state_list_drawable(json_data, component_type)
  126. # Generate content based on attributes
  127. 1 drawable_content = @state_list_generator.generate(json_data, self)
  128. 1 return nil unless drawable_content
  129. # Generate hash-based filename
  130. 1 drawable_hash = @hash_manager.generate_hash(drawable_content)
  131. 1 drawable_name = "selector_#{drawable_hash}"
  132. # Check if drawable already exists
  133. 1 if @hash_manager.drawable_exists?(drawable_name)
  134. return drawable_name
  135. end
  136. # Write the drawable file
  137. 1 drawable_path = File.join(@drawable_dir, "#{drawable_name}.xml")
  138. 1 File.write(drawable_path, drawable_content)
  139. 1 @hash_manager.register_drawable(drawable_name, drawable_content)
  140. 1 drawable_name
  141. end
  142. # Public method for state list generator to create sub-drawables
  143. 1 def create_shape_drawable_for_state(state_data)
  144. 2 return nil unless state_data
  145. 1 generate_shape_drawable(state_data, nil)
  146. end
  147. end
  148. end

lib/xml/drawable/drawable_hash_manager.rb

47.76% lines covered

67 relevant lines. 32 lines covered and 35 lines missed.
    
  1. 1 require 'digest'
  2. 1 require 'json'
  3. 1 module DrawableGenerator
  4. 1 class DrawableHashManager
  5. 1 HASH_REGISTRY_FILE = '.drawable_hashes.json'
  6. 1 def initialize(drawable_dir)
  7. 59 @drawable_dir = drawable_dir
  8. 59 @registry_path = File.join(@drawable_dir, HASH_REGISTRY_FILE)
  9. 59 @registry = load_registry
  10. 59 @session_cache = {}
  11. end
  12. 1 def generate_hash(content)
  13. # Generate a short hash from the content
  14. 22 full_hash = Digest::SHA256.hexdigest(content)
  15. # Use first 8 characters for readability while maintaining uniqueness
  16. 22 full_hash[0..7]
  17. end
  18. 1 def drawable_exists?(drawable_name)
  19. # Check session cache first
  20. 11 return true if @session_cache[drawable_name]
  21. # Check file system
  22. 11 file_path = File.join(@drawable_dir, "#{drawable_name}.xml")
  23. 11 exists = File.exist?(file_path)
  24. # Update cache if exists
  25. 11 @session_cache[drawable_name] = true if exists
  26. 11 exists
  27. end
  28. 1 def register_drawable(drawable_name, content)
  29. # Add to session cache
  30. 11 @session_cache[drawable_name] = true
  31. # Add to registry with metadata
  32. 11 @registry[drawable_name] = {
  33. 'hash' => generate_hash(content),
  34. 'created_at' => Time.now.to_s,
  35. 'content_hash' => Digest::MD5.hexdigest(content)
  36. }
  37. 11 save_registry
  38. end
  39. 1 def find_existing_drawable(content)
  40. content_hash = Digest::MD5.hexdigest(content)
  41. # Search registry for matching content
  42. @registry.each do |name, data|
  43. if data['content_hash'] == content_hash
  44. # Verify file still exists
  45. if drawable_exists?(name)
  46. return name
  47. else
  48. # Clean up orphaned registry entry
  49. @registry.delete(name)
  50. end
  51. end
  52. end
  53. nil
  54. end
  55. 1 def cleanup_orphaned_drawables
  56. orphaned = []
  57. @registry.each do |name, _data|
  58. file_path = File.join(@drawable_dir, "#{name}.xml")
  59. unless File.exist?(file_path)
  60. orphaned << name
  61. end
  62. end
  63. orphaned.each { |name| @registry.delete(name) }
  64. save_registry if orphaned.any?
  65. orphaned
  66. end
  67. 1 def list_drawables
  68. drawables = []
  69. Dir.glob(File.join(@drawable_dir, '*.xml')).each do |file|
  70. name = File.basename(file, '.xml')
  71. next if name == 'ic_launcher_foreground' # Skip system drawables
  72. next if name == 'ic_launcher_background'
  73. drawables << {
  74. name: name,
  75. path: file,
  76. size: File.size(file),
  77. modified: File.mtime(file)
  78. }
  79. end
  80. drawables.sort_by { |d| d[:name] }
  81. end
  82. 1 def get_usage_stats
  83. stats = {
  84. total_drawables: 0,
  85. shape_drawables: 0,
  86. ripple_drawables: 0,
  87. selector_drawables: 0,
  88. total_size: 0,
  89. reuse_count: 0
  90. }
  91. list_drawables.each do |drawable|
  92. stats[:total_drawables] += 1
  93. stats[:total_size] += drawable[:size]
  94. case drawable[:name]
  95. when /^shape_/
  96. stats[:shape_drawables] += 1
  97. when /^ripple_/
  98. stats[:ripple_drawables] += 1
  99. when /^selector_/
  100. stats[:selector_drawables] += 1
  101. end
  102. end
  103. # Count reuses based on session cache
  104. stats[:reuse_count] = @session_cache.size
  105. stats
  106. end
  107. 1 private
  108. 1 def load_registry
  109. 59 return {} unless File.exist?(@registry_path)
  110. begin
  111. JSON.parse(File.read(@registry_path))
  112. rescue JSON::ParserError
  113. {}
  114. end
  115. end
  116. 1 def save_registry
  117. 11 File.write(@registry_path, JSON.pretty_generate(@registry))
  118. rescue => e
  119. puts "Warning: Failed to save drawable registry: #{e.message}"
  120. end
  121. end
  122. end

lib/xml/drawable/ripple_drawable_generator.rb

96.0% lines covered

100 relevant lines. 96 lines covered and 4 lines missed.
    
  1. 1 require_relative '../helpers/resource_resolver'
  2. 1 module DrawableGenerator
  3. 1 class RippleDrawableGenerator
  4. 1 def generate(json_data, component_type)
  5. 26 return nil unless json_data
  6. 25 xml = []
  7. 25 xml << '<?xml version="1.0" encoding="utf-8"?>'
  8. 25 xml << '<ripple xmlns:android="http://schemas.android.com/apk/res/android"'
  9. # Ripple color
  10. 25 ripple_color = determine_ripple_color(json_data, component_type)
  11. 25 xml << " android:color=\"#{ripple_color}\">"
  12. # Content mask and background
  13. 25 if needs_mask?(json_data, component_type)
  14. 10 generate_mask_content(xml, json_data)
  15. else
  16. 15 generate_background_content(xml, json_data)
  17. end
  18. 25 xml << '</ripple>'
  19. 25 xml.join("\n")
  20. end
  21. 1 private
  22. 1 def determine_ripple_color(json_data, component_type)
  23. # Check for explicit ripple color
  24. 25 if json_data['rippleColor']
  25. 1 return parse_color(json_data['rippleColor'])
  26. end
  27. # Check for tap background (can be used as ripple hint)
  28. 24 if json_data['tapBackground']
  29. 1 return parse_color(json_data['tapBackground'])
  30. end
  31. # Default ripple colors based on component type
  32. 23 case component_type
  33. when 'Button'
  34. 11 if json_data['background'] && json_data['background'].start_with?('#')
  35. # Light ripple for dark backgrounds, dark ripple for light
  36. 4 return is_dark_color?(json_data['background']) ? '#40FFFFFF' : '#40000000'
  37. end
  38. 7 return '?attr/colorControlHighlight'
  39. when 'Card', 'ListItem'
  40. 3 return '?attr/colorControlHighlight'
  41. else
  42. # Default semi-transparent ripple
  43. 9 return '#20000000'
  44. end
  45. end
  46. 1 def needs_mask?(json_data, component_type)
  47. # Use mask for borderless ripples or specific components
  48. 30 json_data['rippleBorderless'] == true ||
  49. component_type == 'ImageButton' ||
  50. 24 (component_type == 'Button' && !json_data['background'])
  51. end
  52. 1 def generate_mask_content(xml, json_data)
  53. 10 xml << ' <item android:id="@android:id/mask">'
  54. 10 if json_data['cornerRadius'] || json_data['shape']
  55. 1 xml << ' <shape android:shape="rectangle">'
  56. 1 if json_data['cornerRadius']
  57. 1 radius = parse_dimension(json_data['cornerRadius'])
  58. 1 xml << " <corners android:radius=\"#{radius}\" />"
  59. end
  60. 1 xml << ' <solid android:color="@android:color/white" />'
  61. 1 xml << ' </shape>'
  62. else
  63. 9 xml << ' <color android:color="@android:color/white" />'
  64. end
  65. 10 xml << ' </item>'
  66. end
  67. 1 def generate_background_content(xml, json_data)
  68. # Add background item if specified
  69. 15 if json_data['background'] || json_data['cornerRadius'] || json_data['borderWidth']
  70. 11 xml << ' <item>'
  71. 11 if json_data['cornerRadius'] || json_data['borderWidth']
  72. 3 generate_shape_item(xml, json_data)
  73. 8 elsif json_data['background']
  74. 8 if json_data['background'].start_with?('#')
  75. 7 xml << " <color android:color=\"#{json_data['background']}\" />"
  76. 1 elsif json_data['background'].start_with?('@')
  77. 1 xml << " <color android:color=\"#{json_data['background']}\" />"
  78. else
  79. color = parse_color(json_data['background'])
  80. xml << " <color android:color=\"#{color}\" />"
  81. end
  82. end
  83. 11 xml << ' </item>'
  84. end
  85. end
  86. 1 def generate_shape_item(xml, json_data)
  87. 3 xml << ' <shape android:shape="rectangle">'
  88. # Corner radius
  89. 3 if json_data['cornerRadius']
  90. 2 radius = parse_dimension(json_data['cornerRadius'])
  91. 2 xml << " <corners android:radius=\"#{radius}\" />"
  92. end
  93. # Background color
  94. 3 if json_data['background']
  95. 2 color = parse_color(json_data['background'])
  96. 2 xml << " <solid android:color=\"#{color}\" />"
  97. else
  98. 1 xml << ' <solid android:color="@android:color/transparent" />'
  99. end
  100. # Border
  101. 3 if json_data['borderWidth'] && json_data['borderColor']
  102. 1 width = parse_dimension(json_data['borderWidth'])
  103. 1 color = parse_color(json_data['borderColor'])
  104. 1 xml << ' <stroke'
  105. 1 xml << " android:width=\"#{width}\""
  106. 1 xml << " android:color=\"#{color}\" />"
  107. end
  108. 3 xml << ' </shape>'
  109. end
  110. 1 def is_dark_color?(color_str)
  111. 13 return false unless color_str&.start_with?('#')
  112. # Remove # and parse hex
  113. 11 hex = color_str[1..]
  114. # Handle different hex formats
  115. 11 if hex.length == 6
  116. 8 r = hex[0..1].to_i(16)
  117. 8 g = hex[2..3].to_i(16)
  118. 8 b = hex[4..5].to_i(16)
  119. 3 elsif hex.length == 8
  120. # Skip alpha
  121. 1 r = hex[2..3].to_i(16)
  122. 1 g = hex[4..5].to_i(16)
  123. 1 b = hex[6..7].to_i(16)
  124. 2 elsif hex.length == 3
  125. 2 r = (hex[0] * 2).to_i(16)
  126. 2 g = (hex[1] * 2).to_i(16)
  127. 2 b = (hex[2] * 2).to_i(16)
  128. else
  129. return false
  130. end
  131. # Calculate luminance
  132. 11 luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
  133. 11 luminance < 0.5
  134. end
  135. 1 def parse_dimension(value)
  136. 7 return '0dp' unless value
  137. 6 value_str = value.to_s
  138. # Already has unit
  139. 6 return value_str if value_str =~ /\d+(dp|sp|px|dip|pt|in|mm)$/
  140. # Just a number, add dp
  141. 5 return "#{value_str}dp" if value_str =~ /^\d+$/
  142. value_str
  143. end
  144. 1 def parse_color(value)
  145. 9 return '#000000' unless value
  146. 8 value_str = value.to_s
  147. # Already a color reference
  148. 8 return value_str if value_str.start_with?('@color/', '?attr/')
  149. # Special case for transparent
  150. 6 return '#00000000' if value_str == 'transparent'
  151. # Use ResourceResolver to check for color resources
  152. 5 KjuiTools::Xml::Helpers::ResourceResolver.process_color(value_str)
  153. end
  154. end
  155. end

lib/xml/drawable/shape_drawable_generator.rb

99.06% lines covered

106 relevant lines. 105 lines covered and 1 lines missed.
    
  1. 1 require_relative '../helpers/resource_resolver'
  2. 1 module DrawableGenerator
  3. 1 class ShapeDrawableGenerator
  4. 1 def generate(json_data)
  5. 23 return nil unless json_data
  6. 22 xml = []
  7. 22 xml << '<?xml version="1.0" encoding="utf-8"?>'
  8. # Determine if we need a layer-list for gradient + border
  9. 22 if json_data['gradient'] && json_data['borderWidth']
  10. 2 generate_layered_shape(xml, json_data)
  11. else
  12. 20 generate_simple_shape(xml, json_data)
  13. end
  14. 22 xml.join("\n")
  15. end
  16. 1 private
  17. 1 def generate_simple_shape(xml, json_data)
  18. 20 xml << '<shape xmlns:android="http://schemas.android.com/apk/res/android"'
  19. 20 xml << ' android:shape="rectangle">'
  20. # Corner radius
  21. 20 if json_data['cornerRadius']
  22. 4 radius = parse_dimension(json_data['cornerRadius'])
  23. 4 xml << " <corners android:radius=\"#{radius}\" />"
  24. end
  25. # Background color or gradient
  26. 20 if json_data['gradient']
  27. 5 generate_gradient(xml, json_data['gradient'])
  28. 15 elsif json_data['background']
  29. 6 color = parse_color(json_data['background'])
  30. 6 xml << " <solid android:color=\"#{color}\" />"
  31. end
  32. # Border
  33. 20 if json_data['borderWidth'] && json_data['borderColor']
  34. 1 width = parse_dimension(json_data['borderWidth'])
  35. 1 color = parse_color(json_data['borderColor'])
  36. 1 xml << " <stroke"
  37. 1 xml << " android:width=\"#{width}\""
  38. 1 xml << " android:color=\"#{color}\" />"
  39. end
  40. # Padding
  41. 20 if json_data['padding']
  42. 1 padding = parse_dimension(json_data['padding'])
  43. 1 xml << " <padding"
  44. 1 xml << " android:left=\"#{padding}\""
  45. 1 xml << " android:top=\"#{padding}\""
  46. 1 xml << " android:right=\"#{padding}\""
  47. 1 xml << " android:bottom=\"#{padding}\" />"
  48. 19 elsif json_data['paddingLeft'] || json_data['paddingTop'] ||
  49. json_data['paddingRight'] || json_data['paddingBottom']
  50. 2 xml << " <padding"
  51. 2 xml << " android:left=\"#{parse_dimension(json_data['paddingLeft'] || '0dp')}\""
  52. 2 xml << " android:top=\"#{parse_dimension(json_data['paddingTop'] || '0dp')}\""
  53. 2 xml << " android:right=\"#{parse_dimension(json_data['paddingRight'] || '0dp')}\""
  54. 2 xml << " android:bottom=\"#{parse_dimension(json_data['paddingBottom'] || '0dp')}\" />"
  55. end
  56. 20 xml << '</shape>'
  57. end
  58. 1 def generate_layered_shape(xml, json_data)
  59. 2 xml << '<layer-list xmlns:android="http://schemas.android.com/apk/res/android">'
  60. # Background layer with gradient
  61. 2 xml << ' <item>'
  62. 2 xml << ' <shape android:shape="rectangle">'
  63. 2 if json_data['cornerRadius']
  64. 1 radius = parse_dimension(json_data['cornerRadius'])
  65. 1 xml << " <corners android:radius=\"#{radius}\" />"
  66. end
  67. 2 generate_gradient(xml, json_data['gradient'], ' ')
  68. 2 xml << ' </shape>'
  69. 2 xml << ' </item>'
  70. # Border layer
  71. 2 if json_data['borderWidth'] && json_data['borderColor']
  72. 2 xml << ' <item>'
  73. 2 xml << ' <shape android:shape="rectangle">'
  74. 2 if json_data['cornerRadius']
  75. 1 radius = parse_dimension(json_data['cornerRadius'])
  76. 1 xml << " <corners android:radius=\"#{radius}\" />"
  77. end
  78. 2 width = parse_dimension(json_data['borderWidth'])
  79. 2 color = parse_color(json_data['borderColor'])
  80. 2 xml << " <stroke"
  81. 2 xml << " android:width=\"#{width}\""
  82. 2 xml << " android:color=\"#{color}\" />"
  83. 2 xml << ' </shape>'
  84. 2 xml << ' </item>'
  85. end
  86. 2 xml << '</layer-list>'
  87. end
  88. 1 def generate_gradient(xml, gradient_data, indent = ' ')
  89. 7 return unless gradient_data
  90. # Parse gradient type
  91. 7 gradient_type = gradient_data['type'] || 'linear'
  92. 7 xml << "#{indent}<gradient"
  93. 7 case gradient_type.downcase
  94. when 'linear'
  95. 5 xml << "#{indent} android:type=\"linear\""
  96. 5 angle = gradient_data['angle'] || 0
  97. 5 xml << "#{indent} android:angle=\"#{angle}\""
  98. when 'radial'
  99. 1 xml << "#{indent} android:type=\"radial\""
  100. 1 radius = parse_dimension(gradient_data['radius'] || '100dp')
  101. 1 xml << "#{indent} android:gradientRadius=\"#{radius}\""
  102. when 'sweep'
  103. 1 xml << "#{indent} android:type=\"sweep\""
  104. end
  105. # Colors
  106. 7 if gradient_data['startColor']
  107. 4 color = parse_color(gradient_data['startColor'])
  108. 4 xml << "#{indent} android:startColor=\"#{color}\""
  109. end
  110. 7 if gradient_data['centerColor']
  111. 1 color = parse_color(gradient_data['centerColor'])
  112. 1 xml << "#{indent} android:centerColor=\"#{color}\""
  113. end
  114. 7 if gradient_data['endColor']
  115. 4 color = parse_color(gradient_data['endColor'])
  116. 4 xml << "#{indent} android:endColor=\"#{color}\""
  117. end
  118. # Center position for radial
  119. 7 if gradient_type.downcase == 'radial'
  120. 1 centerX = gradient_data['centerX'] || 0.5
  121. 1 centerY = gradient_data['centerY'] || 0.5
  122. 1 xml << "#{indent} android:centerX=\"#{centerX}\""
  123. 1 xml << "#{indent} android:centerY=\"#{centerY}\""
  124. end
  125. 7 xml << " />"
  126. end
  127. 1 def parse_dimension(value)
  128. 25 return '0dp' unless value
  129. 24 value_str = value.to_s
  130. # Already has unit
  131. 24 return value_str if value_str =~ /\d+(dp|sp|px|dip|pt|in|mm)$/
  132. # Just a number, add dp
  133. 17 return "#{value_str}dp" if value_str =~ /^\d+$/
  134. value_str
  135. end
  136. 1 def parse_color(value)
  137. 21 return '#000000' unless value
  138. 20 value_str = value.to_s
  139. # Already a color reference
  140. 20 return value_str if value_str.start_with?('@color/')
  141. # Special case for transparent
  142. 19 return '#00000000' if value_str == 'transparent'
  143. # Use ResourceResolver to check for color resources
  144. 18 KjuiTools::Xml::Helpers::ResourceResolver.process_color(value_str)
  145. end
  146. end
  147. end

lib/xml/drawable/state_list_drawable_generator.rb

78.18% lines covered

110 relevant lines. 86 lines covered and 24 lines missed.
    
  1. 1 require_relative '../helpers/resource_resolver'
  2. 1 module DrawableGenerator
  3. 1 class StateListDrawableGenerator
  4. 1 def generate(json_data, parent_generator)
  5. 20 return nil unless json_data
  6. 19 @parent_generator = parent_generator
  7. 19 xml = []
  8. 19 xml << '<?xml version="1.0" encoding="utf-8"?>'
  9. 19 xml << '<selector xmlns:android="http://schemas.android.com/apk/res/android">'
  10. # Order matters in state list - most specific states first
  11. # Disabled state
  12. 19 if json_data['disabledBackground'] || json_data['disabledColor']
  13. 3 generate_state_item(xml,
  14. state: 'disabled',
  15. background: json_data['disabledBackground'],
  16. color: json_data['disabledColor'],
  17. original_data: json_data
  18. )
  19. end
  20. # Pressed/Tap state
  21. 19 if json_data['tapBackground'] || json_data['pressedBackground'] || json_data['tapColor']
  22. 3 generate_state_item(xml,
  23. state: 'pressed',
  24. background: json_data['tapBackground'] || json_data['pressedBackground'],
  25. color: json_data['tapColor'],
  26. original_data: json_data
  27. )
  28. end
  29. # Selected state
  30. 19 if json_data['selectedBackground'] || json_data['selectedColor']
  31. 2 generate_state_item(xml,
  32. state: 'selected',
  33. background: json_data['selectedBackground'],
  34. color: json_data['selectedColor'],
  35. original_data: json_data
  36. )
  37. end
  38. # Focused state
  39. 19 if json_data['focusedBackground'] || json_data['focusedColor']
  40. 2 generate_state_item(xml,
  41. state: 'focused',
  42. background: json_data['focusedBackground'],
  43. color: json_data['focusedColor'],
  44. original_data: json_data
  45. )
  46. end
  47. # Checked state (for checkboxes, radio buttons, switches)
  48. 19 if json_data['checkedBackground'] || json_data['checkedColor']
  49. 2 generate_state_item(xml,
  50. state: 'checked',
  51. background: json_data['checkedBackground'],
  52. color: json_data['checkedColor'],
  53. original_data: json_data
  54. )
  55. end
  56. # Default state (always last)
  57. 19 generate_default_state(xml, json_data)
  58. 19 xml << '</selector>'
  59. 19 xml.join("\n")
  60. end
  61. 1 private
  62. 1 def generate_state_item(xml, state:, background:, color:, original_data:)
  63. 12 return unless background || color
  64. # Build state attributes
  65. 12 state_attrs = build_state_attributes(state)
  66. 12 xml << " <item #{state_attrs}>"
  67. 12 if background
  68. 7 if needs_shape?(background, original_data)
  69. # Generate a shape drawable for this state
  70. generate_state_shape(xml, background, original_data)
  71. else
  72. # Simple color
  73. 7 color_value = parse_color(background)
  74. 7 xml << " <color android:color=\"#{color_value}\" />"
  75. end
  76. 5 elsif color
  77. # Text color selector item
  78. 5 color_value = parse_color(color)
  79. 5 xml << " <color android:color=\"#{color_value}\" />"
  80. end
  81. 12 xml << ' </item>'
  82. end
  83. 1 def generate_default_state(xml, json_data)
  84. 19 xml << ' <item>'
  85. 19 if json_data['background'] || json_data['cornerRadius'] || json_data['borderWidth']
  86. 4 if needs_shape?(json_data['background'], json_data)
  87. 2 generate_state_shape(xml, json_data['background'], json_data)
  88. else
  89. # Simple color background
  90. 2 color = parse_color(json_data['background'] || '#FFFFFF')
  91. 2 xml << " <color android:color=\"#{color}\" />"
  92. end
  93. else
  94. # Transparent default
  95. 15 xml << ' <color android:color="@android:color/transparent" />'
  96. end
  97. 19 xml << ' </item>'
  98. end
  99. 1 def generate_state_shape(xml, background, original_data)
  100. 2 xml << ' <shape android:shape="rectangle">'
  101. # Corner radius from original data
  102. 2 if original_data['cornerRadius']
  103. 1 radius = parse_dimension(original_data['cornerRadius'])
  104. 1 xml << " <corners android:radius=\"#{radius}\" />"
  105. end
  106. # Background color or gradient
  107. 2 if background.is_a?(Hash) && background['gradient']
  108. generate_gradient(xml, background['gradient'])
  109. 2 elsif background
  110. 2 color = parse_color(background)
  111. 2 xml << " <solid android:color=\"#{color}\" />"
  112. end
  113. # Border from original data (consistent across states)
  114. 2 if original_data['borderWidth'] && original_data['borderColor']
  115. 1 width = parse_dimension(original_data['borderWidth'])
  116. 1 color = parse_color(original_data['borderColor'])
  117. 1 xml << ' <stroke'
  118. 1 xml << " android:width=\"#{width}\""
  119. 1 xml << " android:color=\"#{color}\" />"
  120. end
  121. 2 xml << ' </shape>'
  122. end
  123. 1 def generate_gradient(xml, gradient_data)
  124. return unless gradient_data
  125. gradient_type = gradient_data['type'] || 'linear'
  126. xml << ' <gradient'
  127. case gradient_type.downcase
  128. when 'linear'
  129. xml << ' android:type="linear"'
  130. angle = gradient_data['angle'] || 0
  131. xml << " android:angle=\"#{angle}\""
  132. when 'radial'
  133. xml << ' android:type="radial"'
  134. radius = parse_dimension(gradient_data['radius'] || '100dp')
  135. xml << " android:gradientRadius=\"#{radius}\""
  136. when 'sweep'
  137. xml << ' android:type="sweep"'
  138. end
  139. # Colors
  140. if gradient_data['startColor']
  141. color = parse_color(gradient_data['startColor'])
  142. xml << " android:startColor=\"#{color}\""
  143. end
  144. if gradient_data['centerColor']
  145. color = parse_color(gradient_data['centerColor'])
  146. xml << " android:centerColor=\"#{color}\""
  147. end
  148. if gradient_data['endColor']
  149. color = parse_color(gradient_data['endColor'])
  150. xml << " android:endColor=\"#{color}\""
  151. end
  152. xml << ' />'
  153. end
  154. 1 def build_state_attributes(state)
  155. 19 case state
  156. when 'disabled'
  157. 4 'android:state_enabled="false"'
  158. when 'pressed'
  159. 4 'android:state_pressed="true"'
  160. when 'selected'
  161. 3 'android:state_selected="true"'
  162. when 'focused'
  163. 3 'android:state_focused="true"'
  164. when 'checked'
  165. 3 'android:state_checked="true"'
  166. when 'activated'
  167. 1 'android:state_activated="true"'
  168. else
  169. 1 ''
  170. end
  171. end
  172. 1 def needs_shape?(background, original_data)
  173. 15 return true if original_data['cornerRadius']
  174. 13 return true if original_data['borderWidth']
  175. 11 return true if background.is_a?(Hash) && background['gradient']
  176. 10 false
  177. end
  178. 1 def parse_dimension(value)
  179. 5 return '0dp' unless value
  180. 4 value_str = value.to_s
  181. # Already has unit
  182. 4 return value_str if value_str =~ /\d+(dp|sp|px|dip|pt|in|mm)$/
  183. # Just a number, add dp
  184. 3 return "#{value_str}dp" if value_str =~ /^\d+$/
  185. value_str
  186. end
  187. 1 def parse_color(value)
  188. 21 return '#000000' unless value
  189. 20 value_str = value.to_s
  190. # Already a color reference
  191. 20 return value_str if value_str.start_with?('@color/', '?attr/')
  192. # Special case for transparent
  193. 18 return '#00000000' if value_str == 'transparent'
  194. # Use ResourceResolver to check for color resources
  195. 17 KjuiTools::Xml::Helpers::ResourceResolver.process_color(value_str)
  196. end
  197. end
  198. end

lib/xml/helpers/attribute_mapper.rb

92.11% lines covered

76 relevant lines. 70 lines covered and 6 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 require 'json'
  3. 1 require_relative 'mappers/dimension_mapper'
  4. 1 require_relative 'mappers/text_mapper'
  5. 1 require_relative 'mappers/layout_mapper'
  6. 1 require_relative 'mappers/style_mapper'
  7. 1 require_relative 'mappers/input_mapper'
  8. 1 module XmlGenerator
  9. 1 class AttributeMapper
  10. 1 def initialize(drawable_generator = nil, string_resource_manager = nil)
  11. 64 @dimension_mapper = Mappers::DimensionMapper.new
  12. 64 @text_mapper = Mappers::TextMapper.new(string_resource_manager)
  13. 64 @layout_mapper = Mappers::LayoutMapper.new(@dimension_mapper)
  14. 64 @style_mapper = Mappers::StyleMapper.new(@text_mapper, drawable_generator)
  15. 64 @input_mapper = Mappers::InputMapper.new
  16. 64 @drawable_generator = drawable_generator
  17. 64 @string_resource_manager = string_resource_manager
  18. 64 @attribute_map = create_attribute_map
  19. end
  20. 1 def map_dimension(value)
  21. 30 @dimension_mapper.map_dimension(value)
  22. end
  23. 1 def map_attribute(key, value, component_type, parent_type = nil, json_element = nil)
  24. # Skip problematic data binding expressions for specific attributes
  25. 25 if should_skip_binding?(key, value, component_type)
  26. 4 log_skipped_binding(key, value, component_type)
  27. 4 puts "Skipping binding: #{key}=#{value} for #{component_type}" if ENV['DEBUG']
  28. 4 return nil
  29. end
  30. # Try layout attributes first (includes dimensions, padding, margin, alignment)
  31. 21 result = @layout_mapper.map_layout_attributes(key, value, component_type, parent_type)
  32. 21 return result if result
  33. # Try alignment attributes
  34. 20 result = @layout_mapper.map_alignment_attributes(key, value, parent_type)
  35. 20 return result if result
  36. # Try text attributes
  37. 18 result = @text_mapper.map_text_attributes(key, value, component_type)
  38. 18 return result if result
  39. # Try style attributes (with json_element for drawable generation)
  40. 15 result = @style_mapper.map_style_attributes(key, value, json_element, component_type)
  41. 15 return result if result
  42. # Try input attributes
  43. 14 result = @input_mapper.map_input_attributes(key, value)
  44. 14 return result if result
  45. # Custom component properties (store as tag or tools attribute)
  46. 14 case key
  47. when 'title'
  48. # Don't use tools: namespace for data binding expressions
  49. 2 if value.to_s.start_with?('@{')
  50. 1 return nil # Skip tools attributes with data binding
  51. end
  52. 1 return { namespace: 'tools', name: 'title', value: value }
  53. when 'count'
  54. # Don't use tools: namespace for data binding expressions
  55. 1 if value.to_s.start_with?('@{')
  56. return nil # Skip tools attributes with data binding
  57. end
  58. 1 return { namespace: 'tools', name: 'count', value: value.to_s }
  59. when /^constraint/
  60. 3 return map_constraint_attribute(key, value)
  61. else
  62. # Check if it's in the standard map
  63. 8 if @attribute_map[key]
  64. 5 mapped = @attribute_map[key]
  65. return {
  66. 5 namespace: mapped[:namespace] || 'android',
  67. name: mapped[:name],
  68. value: convert_value(value, mapped[:type])
  69. }
  70. end
  71. end
  72. nil
  73. end
  74. 1 private
  75. 1 def should_skip_binding?(key, value, component_type)
  76. 25 return false unless value.to_s.include?('@{')
  77. # List of problematic bindings that need to be skipped
  78. problematic_bindings = [
  79. # RecyclerView items binding - Skip this as it needs complex adapter implementation
  80. 5 { key: 'items', component: 'RecyclerView' },
  81. { key: 'items', component: 'Collection' },
  82. # StatusColor binding - Compose UI Color type not supported in data binding
  83. { key: 'tint', value_contains: 'statusColor' },
  84. { key: 'color', value_contains: 'statusColor' }, # color is sometimes mapped to tint
  85. # Visibility binding - String type not supported
  86. { key: 'visibility', value_contains: '@{' },
  87. # Progress binding - double type not supported
  88. { key: 'progress', value_contains: '@{' },
  89. # Slider value binding (maps to progress) - double type not supported
  90. { key: 'value', component: 'Slider', value_contains: '@{' }
  91. ]
  92. 5 problematic_bindings.any? do |binding|
  93. 22 if binding[:component]
  94. 10 key == binding[:key] && component_type&.include?(binding[:component])
  95. 12 elsif binding[:value_contains]
  96. 12 key == binding[:key] && value.to_s.include?(binding[:value_contains])
  97. elsif binding[:type]
  98. key == binding[:key] && value.to_s.include?('.') # Assumes object property access
  99. else
  100. key == binding[:key]
  101. end
  102. end
  103. end
  104. 1 def log_skipped_binding(key, value, component_type)
  105. 4 @skipped_bindings ||= []
  106. 4 @skipped_bindings << {
  107. attribute: key,
  108. value: value,
  109. component: component_type,
  110. reason: 'Requires custom binding adapter'
  111. }
  112. # Write to a file that can be accessed later
  113. 4 File.open('/tmp/skipped_bindings.json', 'w') do |f|
  114. 4 f.write(@skipped_bindings.to_json)
  115. end
  116. end
  117. 1 def create_attribute_map
  118. {
  119. # Additional mappings not covered by specific mappers
  120. 64 'contentDescription' => { name: 'contentDescription', type: 'string' },
  121. 'tag' => { name: 'tag', type: 'string' },
  122. 'transitionName' => { name: 'transitionName', type: 'string' },
  123. 'elevation' => { name: 'elevation', type: 'dimension' },
  124. 'translationZ' => { name: 'translationZ', type: 'dimension' },
  125. 'rotation' => { name: 'rotation', type: 'float' },
  126. 'rotationX' => { name: 'rotationX', type: 'float' },
  127. 'rotationY' => { name: 'rotationY', type: 'float' },
  128. 'scaleX' => { name: 'scaleX', type: 'float' },
  129. 'scaleY' => { name: 'scaleY', type: 'float' }
  130. }
  131. end
  132. 1 def convert_value(value, type)
  133. 5 case type
  134. when 'dimension'
  135. 1 @dimension_mapper.convert_dimension(value)
  136. when 'float'
  137. 2 value.to_f.to_s
  138. when 'integer'
  139. value.to_i.to_s
  140. when 'boolean'
  141. value.to_s
  142. else
  143. 2 value
  144. end
  145. end
  146. 1 def map_constraint_attribute(key, value)
  147. # ConstraintLayout attributes mapping
  148. constraint_map = {
  149. 3 'constraintStartToStartOf' => 'layout_constraintStart_toStartOf',
  150. 'constraintEndToEndOf' => 'layout_constraintEnd_toEndOf',
  151. 'constraintTopToTopOf' => 'layout_constraintTop_toTopOf',
  152. 'constraintBottomToBottomOf' => 'layout_constraintBottom_toBottomOf',
  153. 'constraintStartToEndOf' => 'layout_constraintStart_toEndOf',
  154. 'constraintEndToStartOf' => 'layout_constraintEnd_toStartOf',
  155. 'constraintTopToBottomOf' => 'layout_constraintTop_toBottomOf',
  156. 'constraintBottomToTopOf' => 'layout_constraintBottom_toTopOf'
  157. }
  158. 3 if constraint_map[key]
  159. 3 constraint_value = value == 'parent' ? 'parent' : "@id/#{value}"
  160. 3 return { namespace: 'app', name: constraint_map[key], value: constraint_value }
  161. end
  162. nil
  163. end
  164. end
  165. end

lib/xml/helpers/binding_parser.rb

75.9% lines covered

83 relevant lines. 63 lines covered and 20 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 module XmlGenerator
  3. 1 class BindingParser
  4. 1 def initialize
  5. 34 @bindings = []
  6. end
  7. 1 def parse(value)
  8. # Convert @{variable} syntax to Android data binding
  9. 11 if value.start_with?('@{') && value.end_with?('}')
  10. # Extract the binding expression
  11. 10 expression = value[2..-2]
  12. # Track the binding
  13. 10 @bindings << expression
  14. # Return Android data binding format
  15. 10 "@{#{convert_expression(expression)}}"
  16. else
  17. 1 value
  18. end
  19. end
  20. 1 def get_bindings
  21. 1 @bindings.uniq
  22. end
  23. 1 def has_bindings?
  24. 2 !@bindings.empty?
  25. end
  26. 1 private
  27. 1 def convert_expression(expression)
  28. # Handle different binding patterns
  29. # Simple variable binding: @{userName} -> @{data.userName}
  30. 10 if expression.match?(/^\w+$/)
  31. 5 return "data.#{expression}"
  32. end
  33. # Property access: @{user.name} -> @{data.user.name}
  34. 5 if expression.match?(/^[\w.]+$/)
  35. 1 return "data.#{expression}"
  36. end
  37. # Method call: @{getUserName()} -> @{viewModel.getUserName()}
  38. 4 if expression.include?('(')
  39. 2 if expression.start_with?('viewModel.')
  40. 1 return expression
  41. else
  42. 1 return "viewModel.#{expression}"
  43. end
  44. end
  45. # Conditional expression: @{isVisible ? View.VISIBLE : View.GONE}
  46. 2 if expression.include?('?')
  47. 1 return process_conditional(expression)
  48. end
  49. # String concatenation: @{`Hello ${userName}`}
  50. 1 if expression.include?('${')
  51. 1 return process_string_template(expression)
  52. end
  53. # Default: return as is
  54. expression
  55. end
  56. 1 def process_conditional(expression)
  57. # Convert conditional expressions
  58. 1 parts = expression.split(/\s*\?\s*/)
  59. 1 if parts.length == 2
  60. 1 condition = parts[0]
  61. 1 values = parts[1].split(/\s*:\s*/)
  62. 1 if values.length == 2
  63. # Add data. prefix to condition if it's a simple variable
  64. 1 if condition.match?(/^\w+$/)
  65. 1 condition = "data.#{condition}"
  66. end
  67. # Process visibility values
  68. 1 true_value = process_value(values[0])
  69. 1 false_value = process_value(values[1])
  70. 1 return "#{condition} ? #{true_value} : #{false_value}"
  71. end
  72. end
  73. expression
  74. end
  75. 1 def process_string_template(expression)
  76. # Convert string template: `Hello ${userName}` -> @{`Hello ` + data.userName}
  77. 1 if expression.start_with?('`') && expression.end_with?('`')
  78. 1 template = expression[1..-2]
  79. # Replace ${variable} with ` + data.variable + `
  80. 1 template.gsub!(/\$\{(\w+)\}/) do |match|
  81. 1 "` + data.#{$1} + `"
  82. end
  83. 1 "`#{template}`"
  84. else
  85. expression
  86. end
  87. end
  88. 1 def process_value(value)
  89. # Process special values
  90. 2 case value.strip
  91. when 'true', 'false'
  92. value
  93. when 'VISIBLE', 'View.VISIBLE'
  94. 1 'View.VISIBLE'
  95. when 'INVISIBLE', 'View.INVISIBLE'
  96. 'View.INVISIBLE'
  97. when 'GONE', 'View.GONE'
  98. 1 'View.GONE'
  99. else
  100. # Check if it's a simple variable
  101. if value.match?(/^\w+$/)
  102. "data.#{value}"
  103. else
  104. value
  105. end
  106. end
  107. end
  108. end
  109. 1 class DataBindingManager
  110. 1 def initialize
  111. 3 @variables = Set.new
  112. 3 @imports = Set.new
  113. 3 @converters = []
  114. end
  115. 1 def add_variable(name, type = 'String')
  116. 1 @variables.add({ name: name, type: type })
  117. end
  118. 1 def add_import(class_name)
  119. 1 @imports.add(class_name)
  120. end
  121. 1 def add_converter(converter)
  122. 1 @converters << converter
  123. end
  124. 1 def generate_data_binding_layout(xml_content)
  125. # Wrap the layout in <layout> tags for data binding
  126. doc = Nokogiri::XML(xml_content)
  127. # Create new document with layout root
  128. builder = Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
  129. xml.layout('xmlns:android' => 'http://schemas.android.com/apk/res/android',
  130. 'xmlns:app' => 'http://schemas.android.com/apk/res-auto',
  131. 'xmlns:tools' => 'http://schemas.android.com/tools') do
  132. # Add data section
  133. xml.data do
  134. # Add imports
  135. @imports.each do |import|
  136. xml.import(type: import)
  137. end
  138. # Add variables
  139. @variables.each do |var|
  140. xml.variable(name: var[:name], type: var[:type])
  141. end
  142. # Add ViewModel variable
  143. xml.variable(name: 'viewModel', type: "com.example.viewmodel.#{get_view_model_name}")
  144. end
  145. # Add the original layout content (without XML declaration)
  146. xml << doc.root.to_xml
  147. end
  148. end
  149. builder.to_xml(indent: 4)
  150. end
  151. 1 private
  152. 1 def get_view_model_name
  153. # Generate ViewModel class name from layout name
  154. # This should be passed in or configured
  155. 'MainViewModel'
  156. end
  157. end
  158. end

lib/xml/helpers/component_mapper.rb

93.94% lines covered

33 relevant lines. 31 lines covered and 2 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 module XmlGenerator
  3. 1 class ComponentMapper
  4. 1 def initialize
  5. @component_map = {
  6. # Layout containers
  7. # Note: 'View' is handled specially in map_component method
  8. 61 'HStack' => 'LinearLayout',
  9. 'VStack' => 'LinearLayout',
  10. 'ZStack' => 'FrameLayout',
  11. 'RelativeView' => 'RelativeLayout',
  12. 'ConstraintView' => 'androidx.constraintlayout.widget.ConstraintLayout',
  13. 'ScrollView' => 'ScrollView',
  14. 'HorizontalScrollView' => 'HorizontalScrollView',
  15. # Basic components - Use Kjui custom views for font support
  16. 'Label' => 'com.kotlinjsonui.views.KjuiTextView',
  17. 'Text' => 'com.kotlinjsonui.views.KjuiTextView',
  18. 'Button' => 'com.kotlinjsonui.views.KjuiButton',
  19. 'ImageButton' => 'ImageButton',
  20. 'TextField' => 'com.kotlinjsonui.views.KjuiEditText',
  21. 'SecureField' => 'com.kotlinjsonui.views.KjuiEditText',
  22. 'TextView' => 'com.kotlinjsonui.views.KjuiEditText',
  23. # Images
  24. 'Image' => 'ImageView',
  25. 'NetworkImage' => 'com.kotlinjsonui.views.KjuiNetworkImageView',
  26. 'CircleImage' => 'com.kotlinjsonui.views.KjuiCircleImageView',
  27. # Selection components
  28. 'Switch' => 'Switch',
  29. 'Checkbox' => 'CheckBox',
  30. 'Radio' => 'RadioButton',
  31. 'RadioGroup' => 'RadioGroup',
  32. 'Segment' => 'com.google.android.material.tabs.TabLayout',
  33. 'Picker' => 'Spinner',
  34. 'SelectBox' => 'com.kotlinjsonui.views.KjuiSelectBox',
  35. 'DatePicker' => 'DatePicker',
  36. 'TimePicker' => 'TimePicker',
  37. # Progress
  38. 'ProgressBar' => 'ProgressBar',
  39. 'Slider' => 'SeekBar',
  40. 'Rating' => 'RatingBar',
  41. # Lists
  42. 'List' => 'androidx.recyclerview.widget.RecyclerView',
  43. 'Table' => 'androidx.recyclerview.widget.RecyclerView',
  44. 'Collection' => 'androidx.recyclerview.widget.RecyclerView',
  45. 'Grid' => 'GridLayout',
  46. # Material Design components
  47. 'Card' => 'com.google.android.material.card.MaterialCardView',
  48. 'Chip' => 'com.google.android.material.chip.Chip',
  49. 'ChipGroup' => 'com.google.android.material.chip.ChipGroup',
  50. 'FloatingActionButton' => 'com.google.android.material.floatingactionbutton.FloatingActionButton',
  51. 'BottomNavigation' => 'com.google.android.material.bottomnavigation.BottomNavigationView',
  52. 'NavigationView' => 'com.google.android.material.navigation.NavigationView',
  53. 'AppBar' => 'com.google.android.material.appbar.AppBarLayout',
  54. 'Toolbar' => 'androidx.appcompat.widget.Toolbar',
  55. 'TabLayout' => 'com.google.android.material.tabs.TabLayout',
  56. 'TabView' => 'com.google.android.material.tabs.TabLayout',
  57. # Special components
  58. 'SafeAreaView' => 'com.kotlinjsonui.views.KjuiSafeAreaView',
  59. 'GradientView' => 'com.kotlinjsonui.views.KjuiGradientView',
  60. 'BlurView' => 'com.kotlinjsonui.views.KjuiBlurView',
  61. 'WebView' => 'WebView',
  62. 'VideoView' => 'VideoView',
  63. 'MapView' => 'com.google.android.gms.maps.MapView',
  64. 'AdView' => 'com.google.android.gms.ads.AdView',
  65. # Dividers and spacers
  66. 'Divider' => 'View',
  67. 'Spacer' => 'Space',
  68. # Custom components (will be replaced with includes)
  69. 'Include' => 'include'
  70. }
  71. end
  72. 1 def map_component(type, json_element = nil)
  73. # Special handling for View type
  74. 21 if type == 'View' && json_element
  75. # Check if orientation is specified
  76. 5 if json_element['orientation']
  77. 1 return 'LinearLayout'
  78. else
  79. # Use ConstraintLayout instead of RelativeLayout for better positioning support
  80. 4 return 'androidx.constraintlayout.widget.ConstraintLayout'
  81. end
  82. end
  83. # Check for custom component prefix
  84. 16 if type.start_with?('Custom')
  85. 1 return 'include'
  86. end
  87. # For unknown types, check if they have children
  88. 15 if !@component_map[type] && json_element && (json_element['child'] || json_element['children'])
  89. 1 return 'FrameLayout'
  90. end
  91. 14 @component_map[type] || 'View'
  92. end
  93. 1 def is_container?(type)
  94. 8 containers = ['View', 'HStack', 'VStack', 'ZStack', 'ScrollView',
  95. 'HorizontalScrollView', 'RelativeView', 'ConstraintView',
  96. 'Card', 'List', 'Table', 'Collection', 'Grid',
  97. 'RadioGroup', 'ChipGroup']
  98. 8 containers.include?(type)
  99. end
  100. 1 def needs_adapter?(type)
  101. 4 ['List', 'Table', 'Collection', 'RecyclerView'].include?(type)
  102. end
  103. 1 def is_material_component?(android_class)
  104. 2 android_class.include?('com.google.android.material')
  105. end
  106. 1 def get_layout_params_class(parent_type)
  107. 4 case parent_type
  108. when 'RelativeLayout', 'RelativeView'
  109. 1 'RelativeLayout.LayoutParams'
  110. when 'LinearLayout', 'View', 'HStack', 'VStack'
  111. 1 'LinearLayout.LayoutParams'
  112. when 'FrameLayout', 'ZStack'
  113. 1 'FrameLayout.LayoutParams'
  114. when 'ConstraintLayout', 'ConstraintView'
  115. 1 'ConstraintLayout.LayoutParams'
  116. when 'GridLayout', 'Grid'
  117. 'GridLayout.LayoutParams'
  118. else
  119. 'ViewGroup.LayoutParams'
  120. end
  121. end
  122. 1 def get_orientation(type)
  123. 3 case type
  124. when 'HStack'
  125. 1 'horizontal'
  126. when 'VStack', 'View'
  127. 1 'vertical'
  128. else
  129. nil
  130. end
  131. end
  132. end
  133. end

lib/xml/helpers/data_binding_helper.rb

100.0% lines covered

12 relevant lines. 12 lines covered and 0 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 module XmlGenerator
  3. 1 class DataBindingHelper
  4. 1 def self.process_data_binding(value)
  5. 11 return nil if value.nil?
  6. # Convert @{variable} to Android data binding format
  7. 10 if value.is_a?(String) && value.start_with?('@{') && value.end_with?('}')
  8. # Already in binding format, just ensure proper data. prefix
  9. 7 expr = value[2..-2]
  10. # Add data. prefix if it's a simple variable
  11. 7 if expr.match?(/^\w+$/)
  12. 2 "@{data.#{expr}}"
  13. 5 elsif expr.include?('(') && !expr.include?('viewModel.')
  14. # Method call without viewModel prefix
  15. 2 "@{viewModel.#{expr}}"
  16. else
  17. # Keep as is (already has proper prefix or is complex expression)
  18. 3 value
  19. end
  20. else
  21. 3 value
  22. end
  23. end
  24. end
  25. end

lib/xml/helpers/layout_attribute_processor.rb

76.34% lines covered

93 relevant lines. 71 lines covered and 22 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 require_relative 'data_binding_helper'
  3. 1 module XmlGenerator
  4. 1 class LayoutAttributeProcessor
  5. 1 def initialize(attribute_mapper)
  6. 42 @attribute_mapper = attribute_mapper
  7. end
  8. # Process layout dimensions with weight support
  9. 1 def process_dimensions(json_element, is_root, parent_orientation)
  10. 13 attrs = {}
  11. 13 has_weight = json_element['weight']
  12. # Default dimensions
  13. 13 default_width = 'wrap_content'
  14. 13 default_height = 'wrap_content'
  15. # If root element, default to match_parent
  16. 13 if is_root
  17. 4 default_width = 'match_parent'
  18. 4 default_height = 'match_parent'
  19. # If weight is specified, set the dimension in the orientation direction to 0dp
  20. 9 elsif has_weight && parent_orientation
  21. 4 if parent_orientation == 'horizontal'
  22. 2 default_width = '0dp' if !json_element['width']
  23. 2 elsif parent_orientation == 'vertical'
  24. 2 default_height = '0dp' if !json_element['height']
  25. end
  26. end
  27. 13 attrs['android:layout_width'] = @attribute_mapper.map_dimension(
  28. json_element['width'] || default_width
  29. )
  30. 13 attrs['android:layout_height'] = @attribute_mapper.map_dimension(
  31. json_element['height'] || default_height
  32. )
  33. 13 attrs
  34. end
  35. # Process all attributes with gravity combination support
  36. 1 def process_attributes(json_element, parent_type)
  37. 12 attrs = {}
  38. 12 gravity_values = []
  39. 12 constraint_extras = []
  40. 12 has_constraint_specified = false
  41. # Check if parent is ConstraintLayout
  42. 12 is_constraint_layout = parent_type&.include?('ConstraintLayout')
  43. # Track which constraints have been set
  44. 12 constraint_flags = {
  45. horizontal: false,
  46. vertical: false
  47. }
  48. # Map all attributes
  49. 12 json_element.each do |key, value|
  50. 23 next if ['type', 'child', 'children', 'id', 'width', 'height', 'style', 'data', 'orientation'].include?(key)
  51. 9 android_attr = @attribute_mapper.map_attribute(key, value, json_element['type'], parent_type, json_element)
  52. 9 if android_attr
  53. 7 namespace, attr_name = android_attr[:namespace], android_attr[:name]
  54. 7 attr_value = android_attr[:value]
  55. 7 extra = android_attr[:extra]
  56. # Handle data binding
  57. 7 if attr_value.is_a?(String) && attr_value.start_with?('@{')
  58. attr_value = DataBindingHelper.process_data_binding(attr_value)
  59. end
  60. # Track if constraint attributes are being set
  61. 7 if is_constraint_layout && namespace == 'app'
  62. 2 if attr_name.include?('constraint')
  63. 2 has_constraint_specified = true
  64. # Track horizontal constraints
  65. 2 if attr_name.include?('Start') || attr_name.include?('End') || attr_name.include?('Left') || attr_name.include?('Right')
  66. 1 constraint_flags[:horizontal] = true
  67. end
  68. # Track vertical constraints
  69. 2 if attr_name.include?('Top') || attr_name.include?('Bottom')
  70. 1 constraint_flags[:vertical] = true
  71. end
  72. end
  73. end
  74. # Check for alignment attributes that map to constraints
  75. 7 if is_constraint_layout && ['alignLeft', 'alignRight', 'alignTop', 'alignBottom', 'alignCenterHorizontal', 'alignCenterVertical', 'alignCenterInParent'].include?(key)
  76. 2 has_constraint_specified = true
  77. 2 if key == 'alignLeft' || key == 'alignRight' || key == 'alignCenterHorizontal'
  78. 1 constraint_flags[:horizontal] = true
  79. end
  80. 2 if key == 'alignTop' || key == 'alignBottom' || key == 'alignCenterVertical'
  81. 1 constraint_flags[:vertical] = true
  82. end
  83. 2 if key == 'alignCenterInParent'
  84. constraint_flags[:horizontal] = true
  85. constraint_flags[:vertical] = true
  86. end
  87. end
  88. # Collect gravity values to combine them
  89. 7 if attr_name == 'layout_gravity' && parent_type == 'LinearLayout'
  90. gravity_values << attr_value if value
  91. 7 elsif extra && is_constraint_layout
  92. # Handle special ConstraintLayout cases that need multiple attributes
  93. constraint_extras << { key: key, value: value, extra: extra }
  94. # Still add the primary attribute
  95. if namespace == 'android'
  96. attrs["android:#{attr_name}"] = attr_value
  97. elsif namespace == 'app'
  98. attrs["app:#{attr_name}"] = attr_value
  99. end
  100. else
  101. 7 if namespace == 'android'
  102. 5 attrs["android:#{attr_name}"] = attr_value
  103. 2 elsif namespace == 'app'
  104. 2 attrs["app:#{attr_name}"] = attr_value
  105. elsif namespace == 'tools'
  106. attrs["tools:#{attr_name}"] = attr_value
  107. else
  108. attrs[attr_name] = attr_value
  109. end
  110. end
  111. end
  112. end
  113. # Process ConstraintLayout special cases
  114. 12 if is_constraint_layout && constraint_extras.any?
  115. constraint_extras.each do |item|
  116. case item[:extra]
  117. when 'center_horizontal'
  118. # Add end constraint for horizontal centering
  119. attrs['app:layout_constraintEnd_toEndOf'] = 'parent'
  120. when 'center_vertical'
  121. # Add bottom constraint for vertical centering
  122. attrs['app:layout_constraintBottom_toBottomOf'] = 'parent'
  123. when 'center_in_parent'
  124. # Add all constraints for centering in parent
  125. attrs['app:layout_constraintEnd_toEndOf'] = 'parent'
  126. attrs['app:layout_constraintTop_toTopOf'] = 'parent'
  127. attrs['app:layout_constraintBottom_toBottomOf'] = 'parent'
  128. when 'center_vertical_to_view'
  129. # Add bottom constraint to same view for vertical centering
  130. attrs['app:layout_constraintBottom_toBottomOf'] = "@id/#{item[:value]}"
  131. when 'center_horizontal_to_view'
  132. # Add end constraint to same view for horizontal centering
  133. attrs['app:layout_constraintEnd_toEndOf'] = "@id/#{item[:value]}"
  134. end
  135. end
  136. end
  137. # Add default constraints for ConstraintLayout if none specified
  138. 12 if is_constraint_layout
  139. # Add default horizontal constraint (top-left) if no horizontal constraint specified
  140. 6 if !constraint_flags[:horizontal]
  141. 5 attrs['app:layout_constraintStart_toStartOf'] = 'parent'
  142. end
  143. # Add default vertical constraint (top) if no vertical constraint specified
  144. 6 if !constraint_flags[:vertical]
  145. 5 attrs['app:layout_constraintTop_toTopOf'] = 'parent'
  146. end
  147. end
  148. # Combine gravity values if there are multiple
  149. 12 if gravity_values.any?
  150. attrs['android:layout_gravity'] = gravity_values.join('|')
  151. end
  152. 12 attrs
  153. end
  154. # Process LinearLayout orientation
  155. 1 def process_orientation(view_class, json_element)
  156. 8 attrs = {}
  157. 8 if view_class == 'LinearLayout' && json_element['orientation']
  158. 1 attrs['android:orientation'] = json_element['orientation']
  159. 7 elsif view_class == 'LinearLayout'
  160. # Default to vertical if not specified
  161. 1 attrs['android:orientation'] = 'vertical'
  162. end
  163. 8 attrs
  164. end
  165. end
  166. end

lib/xml/helpers/mappers/dimension_mapper.rb

100.0% lines covered

22 relevant lines. 22 lines covered and 0 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 module XmlGenerator
  3. 1 module Mappers
  4. 1 class DimensionMapper
  5. 1 def map_dimension(value)
  6. # Handle nil or empty string
  7. 47 return 'wrap_content' if value.nil? || value.to_s.empty?
  8. 45 case value
  9. when 'matchParent', 'match_parent'
  10. 13 'match_parent'
  11. when 'wrapContent', 'wrap_content'
  12. 16 'wrap_content'
  13. when Integer, Float
  14. 7 "#{value.to_i}dp"
  15. when /^\d+$/
  16. 1 "#{value}dp"
  17. when /^\d+\.\d+$/
  18. 1 "#{value.to_f.to_i}dp"
  19. when /^\d+dp$/
  20. 3 value
  21. when /^\d+%$/
  22. 1 "0dp" # Will need layout_weight
  23. else
  24. 3 value.to_s.empty? ? 'wrap_content' : value.to_s
  25. end
  26. end
  27. 1 def convert_dimension(value)
  28. 24 case value
  29. when Integer, Float
  30. 19 "#{value.to_i}dp"
  31. when String
  32. 2 if value.match?(/^\d+$/)
  33. 1 "#{value}dp"
  34. else
  35. 1 value
  36. end
  37. when Array
  38. # Use first value for now
  39. 2 convert_dimension(value.first || 0)
  40. else
  41. 1 value.to_s
  42. end
  43. end
  44. end
  45. end
  46. end

lib/xml/helpers/mappers/input_mapper.rb

95.24% lines covered

42 relevant lines. 40 lines covered and 2 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 module XmlGenerator
  3. 1 module Mappers
  4. 1 class InputMapper
  5. 1 def map_input_attributes(key, value)
  6. 45 case key
  7. # Input attributes
  8. when 'inputType'
  9. 6 return { namespace: 'android', name: 'inputType', value: map_input_type(value) }
  10. when 'placeholder'
  11. 1 return { namespace: 'android', name: 'hint', value: value }
  12. when 'editable'
  13. 1 return { namespace: 'android', name: 'editable', value: value.to_s }
  14. when 'singleLine'
  15. 1 return { namespace: 'android', name: 'singleLine', value: value.to_s }
  16. when 'maxLength'
  17. 1 return { namespace: 'android', name: 'maxLength', value: value.to_s }
  18. # Switch/Checkbox
  19. when 'checked', 'isChecked'
  20. 3 return { namespace: 'android', name: 'checked', value: process_checked_value(value) }
  21. # SelectBox/Spinner
  22. when 'selectedItem'
  23. 1 return { namespace: 'app', name: 'selectedValue', value: value }
  24. when 'entries', 'items'
  25. 2 if value.is_a?(Array)
  26. 2 return { namespace: 'app', name: 'items', value: value.join('|') }
  27. else
  28. return { namespace: 'app', name: 'items', value: value }
  29. end
  30. when 'selectItemType'
  31. return { namespace: 'tools', name: 'selectItemType', value: value }
  32. when 'hintColor'
  33. # Process color value through ResourceResolver
  34. 1 color_value = KjuiTools::Xml::Helpers::ResourceResolver.process_color(value)
  35. 1 return { namespace: 'app', name: 'hintColor', value: color_value }
  36. when 'prompt'
  37. 1 return { namespace: 'app', name: 'placeholder', value: value }
  38. # Date picker attributes
  39. when 'datePickerMode', 'datePickerStyle'
  40. 1 return { namespace: 'app', name: 'datePickerMode', value: value }
  41. when 'dateFormat'
  42. 1 return { namespace: 'app', name: 'dateFormat', value: value }
  43. when 'minDate', 'minimumDate'
  44. 1 return { namespace: 'app', name: 'minDate', value: value }
  45. when 'maxDate', 'maximumDate'
  46. 1 return { namespace: 'app', name: 'maxDate', value: value }
  47. # Progress/Slider
  48. when 'progress'
  49. 1 return { namespace: 'android', name: 'progress', value: value.to_s }
  50. when 'max', 'maxValue', 'maximumValue'
  51. 1 return { namespace: 'android', name: 'max', value: value.to_f.to_i.to_s }
  52. when 'min', 'minValue', 'minimumValue'
  53. 1 return { namespace: 'android', name: 'min', value: value.to_f.to_i.to_s }
  54. when 'value'
  55. # For Slider, value maps to progress
  56. 2 return { namespace: 'android', name: 'progress', value: process_binding_value(value) }
  57. when 'onValueChange'
  58. 1 return nil # Handled in code generation
  59. # Events (will be handled in binding)
  60. when 'onClick', 'onclick'
  61. 1 return { namespace: 'android', name: 'onClick', value: value }
  62. when 'onTextChanged'
  63. 1 return nil # Handled in code
  64. end
  65. nil
  66. end
  67. 1 private
  68. 1 def map_input_type(value)
  69. input_type_map = {
  70. 6 'text' => 'text',
  71. 'number' => 'number',
  72. 'phone' => 'phone',
  73. 'email' => 'textEmailAddress',
  74. 'password' => 'textPassword',
  75. 'multiline' => 'textMultiLine'
  76. }
  77. 6 input_type_map[value] || value
  78. end
  79. 1 def process_checked_value(value)
  80. 3 if value.is_a?(String) && value.start_with?('@{')
  81. 1 value
  82. else
  83. 2 value.to_s
  84. end
  85. end
  86. 1 def process_binding_value(value)
  87. 2 if value.is_a?(String) && value.start_with?('@{')
  88. 1 value
  89. else
  90. 1 value.to_s
  91. end
  92. end
  93. end
  94. end
  95. end

lib/xml/helpers/mappers/layout_mapper.rb

62.96% lines covered

108 relevant lines. 68 lines covered and 40 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 module XmlGenerator
  3. 1 module Mappers
  4. 1 class LayoutMapper
  5. 1 def initialize(dimension_mapper)
  6. 105 @dimension_mapper = dimension_mapper
  7. end
  8. 1 def map_layout_attributes(key, value, component_type, parent_type)
  9. 44 case key
  10. # Dimension attributes
  11. when 'width'
  12. 2 return { namespace: 'android', name: 'layout_width', value: @dimension_mapper.map_dimension(value) }
  13. when 'height'
  14. 2 return { namespace: 'android', name: 'layout_height', value: @dimension_mapper.map_dimension(value) }
  15. # Padding attributes
  16. when 'padding', 'paddings'
  17. 4 if value.is_a?(Array)
  18. 1 return { namespace: 'android', name: 'padding', value: @dimension_mapper.convert_dimension(value.first || 0) }
  19. else
  20. 3 return { namespace: 'android', name: 'padding', value: @dimension_mapper.convert_dimension(value) }
  21. end
  22. when 'topPadding', 'paddingTop'
  23. 1 return { namespace: 'android', name: 'paddingTop', value: @dimension_mapper.convert_dimension(value) }
  24. when 'bottomPadding', 'paddingBottom'
  25. 1 return { namespace: 'android', name: 'paddingBottom', value: @dimension_mapper.convert_dimension(value) }
  26. when 'leftPadding', 'paddingLeft', 'startPadding', 'paddingStart'
  27. 1 return { namespace: 'android', name: 'paddingStart', value: @dimension_mapper.convert_dimension(value) }
  28. when 'rightPadding', 'paddingRight', 'endPadding', 'paddingEnd'
  29. 1 return { namespace: 'android', name: 'paddingEnd', value: @dimension_mapper.convert_dimension(value) }
  30. # Margin attributes
  31. when 'margin'
  32. 2 if value.is_a?(Array)
  33. 1 return { namespace: 'android', name: 'layout_margin', value: @dimension_mapper.convert_dimension(value.first || 0) }
  34. else
  35. 1 return { namespace: 'android', name: 'layout_margin', value: @dimension_mapper.convert_dimension(value) }
  36. end
  37. when 'topMargin', 'marginTop'
  38. 1 return { namespace: 'android', name: 'layout_marginTop', value: @dimension_mapper.convert_dimension(value) }
  39. when 'bottomMargin', 'marginBottom'
  40. 1 return { namespace: 'android', name: 'layout_marginBottom', value: @dimension_mapper.convert_dimension(value) }
  41. when 'leftMargin', 'marginLeft', 'startMargin', 'marginStart'
  42. 1 return { namespace: 'android', name: 'layout_marginStart', value: @dimension_mapper.convert_dimension(value) }
  43. when 'rightMargin', 'marginRight', 'endMargin', 'marginEnd'
  44. 1 return { namespace: 'android', name: 'layout_marginEnd', value: @dimension_mapper.convert_dimension(value) }
  45. # Layout specific
  46. when 'orientation'
  47. 1 return { namespace: 'android', name: 'orientation', value: value }
  48. when 'weight'
  49. 1 return { namespace: 'android', name: 'layout_weight', value: value.to_s }
  50. when 'gravity'
  51. 2 return { namespace: 'android', name: 'gravity', value: map_gravity(value) }
  52. when 'layout_gravity'
  53. 1 return { namespace: 'android', name: 'layout_gravity', value: map_gravity(value) }
  54. end
  55. nil
  56. end
  57. 1 def map_alignment_attributes(key, value, parent_type)
  58. # Check if parent is ConstraintLayout
  59. 38 is_constraint_layout = parent_type&.include?('ConstraintLayout')
  60. 38 case key
  61. when 'alignTop'
  62. 3 if parent_type == 'LinearLayout'
  63. 1 return { namespace: 'android', name: 'layout_gravity', value: 'top' } if value
  64. 2 elsif is_constraint_layout
  65. 1 return { namespace: 'app', name: 'layout_constraintTop_toTopOf', value: 'parent' } if value
  66. else
  67. 1 return { namespace: 'android', name: 'layout_alignParentTop', value: value.to_s }
  68. end
  69. when 'alignBottom'
  70. 3 if parent_type == 'LinearLayout'
  71. 1 return { namespace: 'android', name: 'layout_gravity', value: 'bottom' } if value
  72. 2 elsif is_constraint_layout
  73. 2 return { namespace: 'app', name: 'layout_constraintBottom_toBottomOf', value: 'parent' } if value
  74. else
  75. return { namespace: 'android', name: 'layout_alignParentBottom', value: value.to_s }
  76. end
  77. when 'alignLeft', 'alignStart'
  78. 2 if parent_type == 'LinearLayout'
  79. 1 return { namespace: 'android', name: 'layout_gravity', value: 'start' } if value
  80. 1 elsif is_constraint_layout
  81. 1 return { namespace: 'app', name: 'layout_constraintStart_toStartOf', value: 'parent' } if value
  82. else
  83. return { namespace: 'android', name: 'layout_alignParentStart', value: value.to_s }
  84. end
  85. when 'alignRight', 'alignEnd'
  86. 2 if parent_type == 'LinearLayout'
  87. return { namespace: 'android', name: 'layout_gravity', value: 'end' } if value
  88. 2 elsif is_constraint_layout
  89. 2 return { namespace: 'app', name: 'layout_constraintEnd_toEndOf', value: 'parent' } if value
  90. else
  91. return { namespace: 'android', name: 'layout_alignParentEnd', value: value.to_s }
  92. end
  93. when 'centerHorizontal'
  94. 2 if parent_type == 'LinearLayout'
  95. 1 return { namespace: 'android', name: 'layout_gravity', value: 'center_horizontal' } if value
  96. 1 elsif is_constraint_layout
  97. # For horizontal centering in ConstraintLayout, we need both start and end constraints
  98. # This will be handled specially
  99. return { namespace: 'app', name: 'layout_constraintStart_toStartOf', value: 'parent', extra: 'center_horizontal' } if value
  100. else
  101. 1 return { namespace: 'android', name: 'layout_centerHorizontal', value: value.to_s }
  102. end
  103. when 'centerVertical'
  104. if parent_type == 'LinearLayout'
  105. return { namespace: 'android', name: 'layout_gravity', value: 'center_vertical' } if value
  106. elsif is_constraint_layout
  107. # For vertical centering in ConstraintLayout, we need both top and bottom constraints
  108. return { namespace: 'app', name: 'layout_constraintTop_toTopOf', value: 'parent', extra: 'center_vertical' } if value
  109. else
  110. return { namespace: 'android', name: 'layout_centerVertical', value: value.to_s }
  111. end
  112. when 'centerInParent'
  113. 1 if parent_type == 'LinearLayout'
  114. 1 return { namespace: 'android', name: 'layout_gravity', value: 'center' } if value
  115. elsif is_constraint_layout
  116. # For centering in ConstraintLayout, we need all four constraints
  117. return { namespace: 'app', name: 'layout_constraintStart_toStartOf', value: 'parent', extra: 'center_in_parent' } if value
  118. else
  119. return { namespace: 'android', name: 'layout_centerInParent', value: value.to_s }
  120. end
  121. # Relative positioning - align to edges of another view
  122. when 'alignTopView'
  123. if is_constraint_layout
  124. return { namespace: 'app', name: 'layout_constraintTop_toTopOf', value: "@id/#{value}" }
  125. else
  126. return { namespace: 'android', name: 'layout_alignTop', value: "@id/#{value}" }
  127. end
  128. when 'alignBottomView'
  129. if is_constraint_layout
  130. return { namespace: 'app', name: 'layout_constraintBottom_toBottomOf', value: "@id/#{value}" }
  131. else
  132. return { namespace: 'android', name: 'layout_alignBottom', value: "@id/#{value}" }
  133. end
  134. when 'alignLeftView'
  135. if is_constraint_layout
  136. return { namespace: 'app', name: 'layout_constraintStart_toStartOf', value: "@id/#{value}" }
  137. else
  138. return { namespace: 'android', name: 'layout_alignStart', value: "@id/#{value}" }
  139. end
  140. when 'alignRightView'
  141. if is_constraint_layout
  142. return { namespace: 'app', name: 'layout_constraintEnd_toEndOf', value: "@id/#{value}" }
  143. else
  144. return { namespace: 'android', name: 'layout_alignEnd', value: "@id/#{value}" }
  145. end
  146. # Center alignment with another view (ConstraintLayout only)
  147. when 'alignCenterVerticalView'
  148. if is_constraint_layout
  149. # To center vertically with another view, constrain both top and bottom to that view
  150. return { namespace: 'app', name: 'layout_constraintTop_toTopOf', value: "@id/#{value}", extra: 'center_vertical_to_view' }
  151. else
  152. puts "Warning: alignCenterVerticalView requires ConstraintLayout"
  153. return nil
  154. end
  155. when 'alignCenterHorizontalView'
  156. if is_constraint_layout
  157. # To center horizontally with another view, constrain both start and end to that view
  158. return { namespace: 'app', name: 'layout_constraintStart_toStartOf', value: "@id/#{value}", extra: 'center_horizontal_to_view' }
  159. else
  160. puts "Warning: alignCenterHorizontalView requires ConstraintLayout"
  161. return nil
  162. end
  163. # Position relative to another view (outside edges)
  164. when 'alignTopOfView', 'above'
  165. 2 if is_constraint_layout
  166. 1 return { namespace: 'app', name: 'layout_constraintBottom_toTopOf', value: "@id/#{value}" }
  167. else
  168. 1 return { namespace: 'android', name: 'layout_above', value: "@id/#{value}" }
  169. end
  170. when 'alignBottomOfView', 'below'
  171. 2 if is_constraint_layout
  172. 1 return { namespace: 'app', name: 'layout_constraintTop_toBottomOf', value: "@id/#{value}" }
  173. else
  174. 1 return { namespace: 'android', name: 'layout_below', value: "@id/#{value}" }
  175. end
  176. when 'alignLeftOfView', 'toLeftOf'
  177. 1 if is_constraint_layout
  178. return { namespace: 'app', name: 'layout_constraintEnd_toStartOf', value: "@id/#{value}" }
  179. else
  180. 1 return { namespace: 'android', name: 'layout_toStartOf', value: "@id/#{value}" }
  181. end
  182. when 'alignRightOfView', 'toRightOf'
  183. 1 if is_constraint_layout
  184. return { namespace: 'app', name: 'layout_constraintStart_toEndOf', value: "@id/#{value}" }
  185. else
  186. 1 return { namespace: 'android', name: 'layout_toEndOf', value: "@id/#{value}" }
  187. end
  188. end
  189. nil
  190. end
  191. 1 private
  192. 1 def map_gravity(value)
  193. 3 if value.is_a?(Array)
  194. 1 value.join('|')
  195. else
  196. 2 case value
  197. when 'center'
  198. 2 'center'
  199. when 'left', 'start'
  200. 'start'
  201. when 'right', 'end'
  202. 'end'
  203. when 'top'
  204. 'top'
  205. when 'bottom'
  206. 'bottom'
  207. else
  208. value
  209. end
  210. end
  211. end
  212. end
  213. end
  214. end

lib/xml/helpers/mappers/style_mapper.rb

65.55% lines covered

119 relevant lines. 78 lines covered and 41 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 require_relative '../resource_resolver'
  3. 1 module XmlGenerator
  4. 1 module Mappers
  5. 1 class StyleMapper
  6. 1 def initialize(text_mapper, drawable_generator = nil)
  7. 103 @text_mapper = text_mapper
  8. 103 @drawable_generator = drawable_generator
  9. end
  10. 1 def map_style_attributes(key, value, json_element = nil, component_type = nil)
  11. 54 case key
  12. # Background and appearance
  13. when 'background', 'backgroundColor'
  14. # Check if we need to generate a drawable
  15. 3 if @drawable_generator && json_element && needs_drawable?(json_element, component_type)
  16. drawable_name = @drawable_generator.get_background_drawable(json_element, component_type)
  17. if drawable_name
  18. return { namespace: 'android', name: 'background', value: "@drawable/#{drawable_name}" }
  19. end
  20. end
  21. 3 return { namespace: 'android', name: 'background', value: KjuiTools::Xml::Helpers::ResourceResolver.process_color(value) }
  22. when 'cornerRadius'
  23. # Handled in drawable generation
  24. return nil if @drawable_generator
  25. return { namespace: 'tools', name: 'cornerRadius', value: convert_dimension(value) }
  26. when 'borderWidth', 'strokeWidth'
  27. # Handled in drawable generation
  28. return nil if @drawable_generator
  29. return { namespace: 'tools', name: 'strokeWidth', value: convert_dimension(value) }
  30. when 'borderColor', 'strokeColor'
  31. # Handled in drawable generation
  32. return nil if @drawable_generator
  33. return { namespace: 'tools', name: 'strokeColor', value: KjuiTools::Xml::Helpers::ResourceResolver.process_color(value) }
  34. when 'borderStyle'
  35. # Handled in drawable generation if available
  36. return nil if @drawable_generator
  37. return { namespace: 'tools', name: 'borderStyle', value: value }
  38. when 'opacity', 'alpha'
  39. 2 return { namespace: 'android', name: 'alpha', value: value.to_f.to_s }
  40. when 'visibility'
  41. 3 return { namespace: 'android', name: 'visibility', value: map_visibility(value) }
  42. when 'enabled'
  43. 1 return { namespace: 'android', name: 'enabled', value: value.to_s }
  44. when 'clickable'
  45. 1 return { namespace: 'android', name: 'clickable', value: value.to_s }
  46. when 'focusable'
  47. 1 return { namespace: 'android', name: 'focusable', value: value.to_s }
  48. # Image attributes
  49. when 'src', 'source', 'image'
  50. 3 return map_image_source(value, component_type)
  51. when 'url'
  52. # For NetworkImageView and CircleImageView
  53. 1 return { namespace: 'app', name: 'url', value: value }
  54. when 'placeholderImage'
  55. # For NetworkImageView placeholder image
  56. 1 if value.start_with?('@drawable/')
  57. return { namespace: 'app', name: 'placeholderImage', value: value }
  58. else
  59. 1 resource_name = value.gsub(/\.\w+$/, '').downcase.gsub(/[^a-z0-9_]/, '_')
  60. 1 return { namespace: 'app', name: 'placeholderImage', value: "@drawable/#{resource_name}" }
  61. end
  62. when 'placeholder'
  63. # For NetworkImageView/CircleImageView, use placeholderImage
  64. 1 if component_type && ['NetworkImage', 'CircleImage'].include?(component_type)
  65. 1 if value.start_with?('@drawable/')
  66. return { namespace: 'app', name: 'placeholderImage', value: value }
  67. else
  68. 1 resource_name = value.gsub(/\.\w+$/, '').downcase.gsub(/[^a-z0-9_]/, '_')
  69. 1 return { namespace: 'app', name: 'placeholderImage', value: "@drawable/#{resource_name}" }
  70. end
  71. end
  72. # For other components, let input_mapper handle it as hint
  73. return nil
  74. when 'errorImage', 'failureImage'
  75. # For NetworkImageView error image
  76. 1 if value.start_with?('@drawable/')
  77. return { namespace: 'app', name: 'errorImage', value: value }
  78. else
  79. 1 resource_name = value.gsub(/\.\w+$/, '').downcase.gsub(/[^a-z0-9_]/, '_')
  80. 1 return { namespace: 'app', name: 'errorImage', value: "@drawable/#{resource_name}" }
  81. end
  82. when 'defaultImage', 'fallbackImage'
  83. # For NetworkImageView default/fallback image
  84. if value.start_with?('@drawable/')
  85. return { namespace: 'app', name: 'defaultImage', value: value }
  86. else
  87. resource_name = value.gsub(/\.\w+$/, '').downcase.gsub(/[^a-z0-9_]/, '_')
  88. return { namespace: 'app', name: 'defaultImage', value: "@drawable/#{resource_name}" }
  89. end
  90. when 'crossfadeEnabled', 'crossfade'
  91. 1 return { namespace: 'app', name: 'crossfadeEnabled', value: value.to_s }
  92. when 'cacheEnabled'
  93. 1 return { namespace: 'app', name: 'cacheEnabled', value: value.to_s }
  94. when 'scaleType'
  95. 2 return { namespace: 'android', name: 'scaleType', value: map_scale_type(value) }
  96. when 'tint'
  97. 1 return { namespace: 'android', name: 'tint', value: KjuiTools::Xml::Helpers::ResourceResolver.process_color(value) }
  98. # Blur attributes
  99. when 'blurRadius'
  100. 1 return { namespace: 'app', name: 'blurRadius', value: value.to_f.to_s }
  101. when 'blurOverlayColor'
  102. 1 return { namespace: 'app', name: 'blurOverlayColor', value: KjuiTools::Xml::Helpers::ResourceResolver.process_color(value) }
  103. when 'downsampleFactor'
  104. 1 return { namespace: 'app', name: 'downsampleFactor', value: value.to_f.to_s }
  105. when 'blurEnabled'
  106. 1 return { namespace: 'app', name: 'blurEnabled', value: value.to_s }
  107. # Gradient attributes
  108. when 'gradientStartColor', 'startColor'
  109. 1 return { namespace: 'app', name: 'gradientStartColor', value: KjuiTools::Xml::Helpers::ResourceResolver.process_color(value) }
  110. when 'gradientEndColor', 'endColor'
  111. 1 return { namespace: 'app', name: 'gradientEndColor', value: KjuiTools::Xml::Helpers::ResourceResolver.process_color(value) }
  112. when 'gradientCenterColor', 'centerColor'
  113. return { namespace: 'app', name: 'gradientCenterColor', value: KjuiTools::Xml::Helpers::ResourceResolver.process_color(value) }
  114. when 'gradientColors', 'colors'
  115. # Handle array of colors - don't process through ResourceResolver
  116. # gradientColors expects raw color values separated by |
  117. 1 if value.is_a?(Array)
  118. 4 colors_string = value.map { |c| normalize_color_for_gradient(c) }.join('|')
  119. 1 return { namespace: 'app', name: 'gradientColors', value: colors_string }
  120. else
  121. return { namespace: 'app', name: 'gradientColors', value: value }
  122. end
  123. when 'gradientDirection', 'direction'
  124. 2 return { namespace: 'app', name: 'gradientOrientation', value: map_gradient_direction(value) }
  125. when 'gradientAngle', 'angle'
  126. 1 return { namespace: 'app', name: 'gradientAngle', value: value.to_s }
  127. when 'gradientType'
  128. 2 return { namespace: 'app', name: 'gradientType', value: map_gradient_type(value) }
  129. when 'gradientRadius'
  130. 1 return { namespace: 'app', name: 'gradientRadius', value: value.to_f.to_s }
  131. when 'gradientCenterX'
  132. return { namespace: 'app', name: 'gradientCenterX', value: value.to_f.to_s }
  133. when 'gradientCenterY'
  134. return { namespace: 'app', name: 'gradientCenterY', value: value.to_f.to_s }
  135. # SafeAreaView attributes
  136. when 'safeAreaInsetPositions', 'insetPositions'
  137. # Handle array of positions
  138. 1 if value.is_a?(Array)
  139. 1 positions_string = value.join('|')
  140. 1 return { namespace: 'app', name: 'safeAreaInsetPositions', value: positions_string }
  141. else
  142. return { namespace: 'app', name: 'safeAreaInsetPositions', value: value }
  143. end
  144. when 'contentInsetAdjustmentBehavior'
  145. return { namespace: 'app', name: 'contentInsetAdjustmentBehavior', value: value.to_s }
  146. when 'applyTopInset'
  147. 1 return { namespace: 'app', name: 'applyTopInset', value: value.to_s }
  148. when 'applyBottomInset'
  149. 1 return { namespace: 'app', name: 'applyBottomInset', value: value.to_s }
  150. when 'applyLeftInset'
  151. return { namespace: 'app', name: 'applyLeftInset', value: value.to_s }
  152. when 'applyRightInset'
  153. return { namespace: 'app', name: 'applyRightInset', value: value.to_s }
  154. when 'applyStartInset'
  155. return { namespace: 'app', name: 'applyStartInset', value: value.to_s }
  156. when 'applyEndInset'
  157. return { namespace: 'app', name: 'applyEndInset', value: value.to_s }
  158. # State-specific attributes (handled by drawable generation)
  159. when 'disabledBackground', 'tapBackground', 'pressedBackground',
  160. 'selectedBackground', 'focusedBackground', 'checkedBackground',
  161. 'rippleColor', 'rippleBorderless'
  162. # These are handled by drawable generation
  163. return nil if @drawable_generator
  164. return { namespace: 'tools', name: key, value: value.to_s }
  165. end
  166. nil
  167. end
  168. 1 private
  169. 1 def needs_drawable?(json_element, component_type)
  170. return false unless json_element
  171. # Check if any drawable-related attributes exist
  172. json_element['cornerRadius'] ||
  173. json_element['borderWidth'] ||
  174. json_element['borderColor'] ||
  175. json_element['gradient'] ||
  176. json_element['disabledBackground'] ||
  177. json_element['tapBackground'] ||
  178. json_element['pressedBackground'] ||
  179. json_element['selectedBackground'] ||
  180. json_element['focusedBackground'] ||
  181. json_element['checkedBackground'] ||
  182. json_element['onClick'] ||
  183. json_element['onclick'] ||
  184. json_element['rippleColor'] ||
  185. ['Button', 'ImageButton', 'Card', 'ListItem'].include?(component_type)
  186. end
  187. 1 def convert_dimension(value)
  188. case value
  189. when Integer, Float
  190. "#{value.to_i}dp"
  191. when String
  192. if value.match?(/^\d+$/)
  193. "#{value}dp"
  194. else
  195. value
  196. end
  197. else
  198. value.to_s
  199. end
  200. end
  201. 1 def map_visibility(value)
  202. 3 case value
  203. when true, 'visible'
  204. 1 'visible'
  205. when false, 'gone'
  206. 1 'gone'
  207. when 'invisible'
  208. 1 'invisible'
  209. else
  210. value
  211. end
  212. end
  213. 1 def map_image_source(value, component_type = nil)
  214. # For NetworkImageView and CircleImageView, map src to url attribute
  215. 3 if component_type && ['NetworkImage', 'CircleImage'].include?(component_type)
  216. 1 return { namespace: 'app', name: 'url', value: value }
  217. end
  218. 2 if value.start_with?('http')
  219. # Network image - use tools for documentation
  220. 1 { namespace: 'tools', name: 'src', value: value }
  221. else
  222. # Local resource
  223. 1 resource_name = value.gsub(/\.\w+$/, '').downcase.gsub(/[^a-z0-9_]/, '_')
  224. 1 { namespace: 'android', name: 'src', value: "@drawable/#{resource_name}" }
  225. end
  226. end
  227. 1 def map_scale_type(value)
  228. scale_type_map = {
  229. 2 'fill' => 'centerCrop',
  230. 'fit' => 'fitCenter',
  231. 'stretch' => 'fitXY',
  232. 'center' => 'center'
  233. }
  234. 2 scale_type_map[value] || value
  235. end
  236. 1 def map_gradient_direction(value)
  237. direction_map = {
  238. 2 'vertical' => 'top_bottom',
  239. 'horizontal' => 'left_right',
  240. 'diagonal' => 'tl_br',
  241. 'diagonal_reverse' => 'tr_bl',
  242. 'topBottom' => 'top_bottom',
  243. 'bottomTop' => 'bottom_top',
  244. 'leftRight' => 'left_right',
  245. 'rightLeft' => 'right_left',
  246. 'rightToLeft' => 'right_left',
  247. 'leftToRight' => 'left_right',
  248. 'topToBottom' => 'top_bottom',
  249. 'bottomToTop' => 'bottom_top',
  250. 'tlBr' => 'tl_br',
  251. 'trBl' => 'tr_bl',
  252. 'blTr' => 'bl_tr',
  253. 'brTl' => 'br_tl'
  254. }
  255. 2 direction_map[value] || 'top_bottom' # Default to top_bottom for unknown values
  256. end
  257. 1 def map_gradient_type(value)
  258. type_map = {
  259. 2 'linear' => 'linear',
  260. 'radial' => 'radial',
  261. 'sweep' => 'sweep',
  262. 'angular' => 'sweep'
  263. }
  264. 2 type_map[value] || 'linear'
  265. end
  266. 1 def normalize_color_for_gradient(color)
  267. 3 return '#00000000' if color == 'clear' || color == 'transparent'
  268. # Ensure hex format for colors
  269. 3 if color.match?(/^#?[A-Fa-f0-9]{6,8}$/)
  270. 3 color.start_with?('#') ? color : "##{color}"
  271. else
  272. # Return as-is for named colors or other formats
  273. color
  274. end
  275. end
  276. end
  277. end
  278. end

lib/xml/helpers/mappers/text_mapper.rb

93.06% lines covered

72 relevant lines. 67 lines covered and 5 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 require_relative '../resource_resolver'
  3. 1 module XmlGenerator
  4. 1 module Mappers
  5. 1 class TextMapper
  6. 1 def initialize(string_resource_manager = nil)
  7. 141 @string_resource_manager = string_resource_manager
  8. end
  9. 1 def map_text_attributes(key, value, component_type)
  10. 54 case key
  11. when 'text'
  12. 5 return { namespace: 'android', name: 'text', value: process_text_value(value) }
  13. when 'hint'
  14. 2 hint_value = process_hint_value(value)
  15. 2 return { namespace: 'android', name: 'hint', value: hint_value }
  16. when 'fontSize', 'textSize'
  17. 4 return { namespace: 'android', name: 'textSize', value: convert_text_size(value) }
  18. when 'fontColor', 'textColor'
  19. 2 return { namespace: 'android', name: 'textColor', value: convert_color(value) }
  20. when 'color'
  21. # Generic color attribute - determine based on component type
  22. 3 if ['Label', 'Text', 'TextView', 'Button'].include?(component_type)
  23. 2 return { namespace: 'android', name: 'textColor', value: convert_color(value) }
  24. else
  25. 1 return { namespace: 'android', name: 'tint', value: convert_color(value) }
  26. end
  27. when 'font'
  28. # Check if it's a font weight/style or a font file name
  29. 7 if ['bold', 'italic', 'normal', 'bold_italic'].include?(value.to_s.downcase)
  30. # It's a text style
  31. 2 return map_font_weight(value)
  32. 5 elsif ['Label', 'Text', 'TextView', 'TextField', 'SecureField', 'Button'].include?(component_type)
  33. # It's a font file name for Kjui views
  34. # Add .ttf extension if not present
  35. 4 font_file = value.to_s
  36. 4 font_file += '.ttf' unless font_file.end_with?('.ttf', '.otf')
  37. 4 return { namespace: 'app', name: 'kjui_font_name', value: font_file }
  38. else
  39. # For non-Kjui views, use as fontFamily
  40. 1 return { namespace: 'android', name: 'fontFamily', value: value }
  41. end
  42. when 'fontFamily'
  43. # fontFamily is always treated as a font file name
  44. 2 if ['Label', 'Text', 'TextView', 'TextField', 'SecureField', 'Button'].include?(component_type)
  45. 1 font_file = value.to_s
  46. 1 font_file += '.ttf' unless font_file.end_with?('.ttf', '.otf')
  47. 1 return { namespace: 'app', name: 'kjui_font_name', value: font_file }
  48. else
  49. 1 return { namespace: 'android', name: 'fontFamily', value: value }
  50. end
  51. when 'fontWeight'
  52. 6 return map_font_weight(value)
  53. when 'fontStyle'
  54. return { namespace: 'android', name: 'textStyle', value: value }
  55. when 'textAlign', 'textAlignment'
  56. 5 return { namespace: 'android', name: 'textAlignment', value: map_text_alignment(value) }
  57. when 'maxLines'
  58. 1 return { namespace: 'android', name: 'maxLines', value: value.to_s }
  59. when 'ellipsize'
  60. 1 return { namespace: 'android', name: 'ellipsize', value: value }
  61. end
  62. nil
  63. end
  64. 1 private
  65. 1 def process_hint_value(value)
  66. # Handle data binding
  67. 2 if value.is_a?(String) && value.start_with?('@{')
  68. 1 return value
  69. end
  70. # Convert value to string
  71. 1 text = value.to_s
  72. # Use ResourceResolver to check for string resources
  73. 1 KjuiTools::Xml::Helpers::ResourceResolver.process_text(text)
  74. end
  75. 1 def process_text_value(value)
  76. # Handle data binding
  77. 5 if value.is_a?(String) && value.start_with?('@{')
  78. 1 return value
  79. end
  80. # Convert value to string
  81. 4 text = value.to_s
  82. # Use ResourceResolver to check for string resources
  83. 4 KjuiTools::Xml::Helpers::ResourceResolver.process_text(text)
  84. end
  85. 1 def convert_text_size(value)
  86. 4 case value
  87. when Integer, Float
  88. 2 "#{value}sp"
  89. when String
  90. 2 if value.match?(/^\d+$/)
  91. 1 "#{value}sp"
  92. else
  93. 1 value
  94. end
  95. else
  96. "14sp"
  97. end
  98. end
  99. 1 def convert_color(value)
  100. 5 return nil if value.nil?
  101. # Handle special color values
  102. 5 if value.is_a?(String)
  103. 5 if value == 'clear' || value == 'transparent'
  104. return '#00000000'
  105. end
  106. # Use ResourceResolver to check for color resources
  107. 5 return KjuiTools::Xml::Helpers::ResourceResolver.process_color(value)
  108. else
  109. value.to_s
  110. end
  111. end
  112. 1 def map_font_weight(value)
  113. 8 case value.to_s.downcase
  114. when 'bold'
  115. 2 { namespace: 'android', name: 'textStyle', value: 'bold' }
  116. when 'italic'
  117. 2 { namespace: 'android', name: 'textStyle', value: 'italic' }
  118. when 'bold_italic', 'bolditalic'
  119. 1 { namespace: 'android', name: 'textStyle', value: 'bold|italic' }
  120. when 'normal', 'regular', 'light', 'thin'
  121. 1 { namespace: 'android', name: 'textStyle', value: 'normal' }
  122. when 'medium', 'semibold', 'heavy', 'black'
  123. # Medium and similar weights map to bold in Android
  124. 1 { namespace: 'android', name: 'textStyle', value: 'bold' }
  125. else
  126. # Default to normal for unknown values
  127. 1 { namespace: 'android', name: 'textStyle', value: 'normal' }
  128. end
  129. end
  130. 1 def map_text_alignment(value)
  131. 5 case value
  132. when 'left', 'start'
  133. 2 'textStart'
  134. when 'right', 'end'
  135. 2 'textEnd'
  136. when 'center'
  137. 1 'center'
  138. else
  139. value
  140. end
  141. end
  142. end
  143. end
  144. end

lib/xml/helpers/resource_resolver.rb

72.16% lines covered

97 relevant lines. 70 lines covered and 27 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'json'
  3. 1 require_relative '../../core/logger'
  4. 1 module KjuiTools
  5. 1 module Xml
  6. 1 module Helpers
  7. 1 class ResourceResolver
  8. 1 class << self
  9. # Load resources lazily
  10. 1 def strings_data
  11. 6 @strings_data ||= load_strings_data
  12. end
  13. 1 def colors_data
  14. 112 @colors_data ||= load_colors_data
  15. end
  16. 1 def defined_colors_data
  17. 56 @defined_colors_data ||= load_defined_colors_data
  18. end
  19. # Clear cache (useful when resources change)
  20. 1 def clear_cache
  21. 19 @strings_data = nil
  22. 19 @colors_data = nil
  23. 19 @defined_colors_data = nil
  24. end
  25. # Process text value - returns @string/key or original text
  26. 1 def process_text(text)
  27. 10 return text if text.nil? || text.empty?
  28. # Skip data binding expressions
  29. 8 return text if text.start_with?('@{') || text.start_with?('${')
  30. # Find string key
  31. 6 string_key = find_string_key(text)
  32. 6 if string_key
  33. "@string/#{string_key}"
  34. else
  35. # Return original text wrapped in quotes for XML
  36. 6 "\"#{text}\""
  37. end
  38. end
  39. # Process color value - returns @color/key or hex color
  40. 1 def process_color(color)
  41. 61 return color if color.nil? || color.empty?
  42. # Skip data binding expressions
  43. 59 return color if color.start_with?('@{') || color.start_with?('${')
  44. # Skip if already a resource reference
  45. 57 return color if color.start_with?('@')
  46. # Find color key
  47. 56 color_key = find_color_key(color)
  48. 56 if color_key
  49. "@color/#{color_key}"
  50. else
  51. # Return hex color with # prefix
  52. 56 if color.match?(/^#?[A-Fa-f0-9]{6,8}$/)
  53. 56 color.start_with?('#') ? color : "##{color}"
  54. else
  55. color
  56. end
  57. end
  58. end
  59. 1 private
  60. 1 def load_strings_data
  61. 3 strings_file = find_strings_json
  62. 3 return {} unless strings_file && File.exist?(strings_file)
  63. begin
  64. data = JSON.parse(File.read(strings_file))
  65. # Flatten the nested structure (file -> key -> value)
  66. flattened = {}
  67. data.each do |file_prefix, file_strings|
  68. next unless file_strings.is_a?(Hash)
  69. file_strings.each do |key, value|
  70. full_key = "#{file_prefix}_#{key}"
  71. flattened[full_key] = value
  72. end
  73. end
  74. flattened
  75. rescue JSON::ParserError => e
  76. Core::Logger.warn "Failed to parse strings.json: #{e.message}"
  77. {}
  78. end
  79. end
  80. 1 def load_colors_data
  81. 5 colors_file = find_colors_json
  82. 5 return {} unless colors_file && File.exist?(colors_file)
  83. begin
  84. JSON.parse(File.read(colors_file))
  85. rescue JSON::ParserError => e
  86. Core::Logger.warn "Failed to parse colors.json: #{e.message}"
  87. {}
  88. end
  89. end
  90. 1 def load_defined_colors_data
  91. 5 defined_colors_file = find_defined_colors_json
  92. 5 return {} unless defined_colors_file && File.exist?(defined_colors_file)
  93. begin
  94. JSON.parse(File.read(defined_colors_file))
  95. rescue JSON::ParserError => e
  96. Core::Logger.warn "Failed to parse defined_colors.json: #{e.message}"
  97. {}
  98. end
  99. end
  100. 1 def find_strings_json
  101. # Try common locations
  102. 3 paths = [
  103. 'src/main/assets/Layouts/Resources/strings.json',
  104. 'app/src/main/assets/Layouts/Resources/strings.json',
  105. 'sample-app/src/main/assets/Layouts/Resources/strings.json'
  106. ]
  107. 3 paths.each do |path|
  108. 9 full_path = File.expand_path(path)
  109. 9 return full_path if File.exist?(full_path)
  110. end
  111. nil
  112. end
  113. 1 def find_colors_json
  114. # Try common locations
  115. 5 paths = [
  116. 'src/main/assets/Layouts/Resources/colors.json',
  117. 'app/src/main/assets/Layouts/Resources/colors.json',
  118. 'sample-app/src/main/assets/Layouts/Resources/colors.json'
  119. ]
  120. 5 paths.each do |path|
  121. 15 full_path = File.expand_path(path)
  122. 15 return full_path if File.exist?(full_path)
  123. end
  124. nil
  125. end
  126. 1 def find_defined_colors_json
  127. # Try common locations
  128. 5 paths = [
  129. 'src/main/assets/Layouts/Resources/defined_colors.json',
  130. 'app/src/main/assets/Layouts/Resources/defined_colors.json',
  131. 'sample-app/src/main/assets/Layouts/Resources/defined_colors.json'
  132. ]
  133. 5 paths.each do |path|
  134. 15 full_path = File.expand_path(path)
  135. 15 return full_path if File.exist?(full_path)
  136. end
  137. nil
  138. end
  139. 1 def find_string_key(text)
  140. 6 strings_data.find { |key, value| value == text }&.first
  141. end
  142. 1 def find_color_key(color)
  143. # If the color itself is a key in colors.json, return it
  144. 56 if colors_data.key?(color)
  145. return color
  146. end
  147. # If the color itself is in defined_colors.json, return it
  148. 56 if defined_colors_data.key?(color)
  149. return color
  150. end
  151. # Otherwise, normalize and search for hex values
  152. 56 normalized_color = normalize_color(color)
  153. # Check colors.json for hex values
  154. 56 if colors_data.any? && normalized_color
  155. found = colors_data.find { |key, value| normalize_color(value) == normalized_color }
  156. return found.first if found
  157. end
  158. nil
  159. end
  160. 1 def normalize_color(color)
  161. 61 return nil if color.nil?
  162. # If it's a hex color, normalize it
  163. 60 if color.match?(/^#?[A-Fa-f0-9]{6,8}$/)
  164. 58 hex = color.gsub('#', '').upcase
  165. # Convert 3-digit to 6-digit
  166. 58 if hex.length == 3
  167. hex = hex.chars.map { |c| c * 2 }.join
  168. end
  169. 58 "##{hex}"
  170. else
  171. 2 color
  172. end
  173. end
  174. end
  175. end
  176. end
  177. end
  178. end

lib/xml/resources/string_resource_manager.rb

37.8% lines covered

82 relevant lines. 31 lines covered and 51 lines missed.
    
  1. 1 require 'nokogiri'
  2. 1 require 'fileutils'
  3. 1 module XmlGenerator
  4. 1 module Resources
  5. 1 class StringResourceManager
  6. 1 def initialize(project_root)
  7. 24 @project_root = project_root
  8. 24 @strings_file_path = find_strings_file
  9. 24 @strings_cache = {}
  10. 24 @new_strings = {}
  11. 24 load_existing_strings
  12. end
  13. # Get or create a string resource reference
  14. 1 def get_string_resource(text)
  15. return nil if text.nil? || text.empty?
  16. # Check if it's already a resource reference
  17. return text if text.start_with?('@string/')
  18. # Check if it's a data binding expression
  19. return text if text.start_with?('@{')
  20. # Check if text is too short or just numbers
  21. return text if text.length < 2 || text.match?(/^\d+$/)
  22. # Check existing strings
  23. existing_name = find_existing_string(text)
  24. return "@string/#{existing_name}" if existing_name
  25. # Check if we already created this string in this session
  26. new_name = @new_strings.key(text)
  27. return "@string/#{new_name}" if new_name
  28. # Create new string resource
  29. string_name = generate_string_name(text)
  30. @new_strings[string_name] = text
  31. "@string/#{string_name}"
  32. end
  33. # Save all new strings to strings.xml
  34. 1 def save_new_strings
  35. 3 return if @new_strings.empty?
  36. ensure_strings_file_exists
  37. # Load the XML file
  38. doc = Nokogiri::XML(File.read(@strings_file_path)) do |config|
  39. config.default_xml.noblanks
  40. end
  41. resources = doc.at_xpath('//resources')
  42. # Add new strings
  43. @new_strings.each do |name, value|
  44. # Skip if already exists (double check)
  45. next if doc.at_xpath("//string[@name='#{name}']")
  46. # Create new string element
  47. string_element = Nokogiri::XML::Node.new('string', doc)
  48. string_element['name'] = name
  49. # Process the value to handle line breaks properly
  50. processed_value = escape_xml_text(value)
  51. # Replace line breaks with \n for XML
  52. processed_value = processed_value.gsub(/\r?\n/, '\n')
  53. string_element.content = processed_value
  54. # Add to resources
  55. resources.add_child("\n ")
  56. resources.add_child(string_element)
  57. end
  58. # Add final newline if there are children
  59. if resources.children.any?
  60. resources.add_child("\n")
  61. end
  62. # Save the file
  63. File.write(@strings_file_path, doc.to_xml(
  64. indent: 4,
  65. indent_text: ' ',
  66. save_with: Nokogiri::XML::Node::SaveOptions::FORMAT |
  67. Nokogiri::XML::Node::SaveOptions::AS_XML
  68. ))
  69. puts "✅ Added #{@new_strings.size} new strings to strings.xml"
  70. # Add to cache for future lookups
  71. @strings_cache.merge!(@new_strings)
  72. @new_strings.clear
  73. end
  74. 1 private
  75. 1 def find_strings_file
  76. possible_paths = [
  77. 24 File.join(@project_root, 'src', 'main', 'res', 'values', 'strings.xml'),
  78. File.join(@project_root, 'app', 'src', 'main', 'res', 'values', 'strings.xml'),
  79. File.join(@project_root, 'sample-app', 'src', 'main', 'res', 'values', 'strings.xml')
  80. ]
  81. 94 possible_paths.find { |path| File.exist?(path) } || possible_paths.first
  82. end
  83. 1 def ensure_strings_file_exists
  84. return if File.exist?(@strings_file_path)
  85. # Create directory if needed
  86. FileUtils.mkdir_p(File.dirname(@strings_file_path))
  87. # Create basic strings.xml
  88. content = <<~XML
  89. <?xml version="1.0" encoding="utf-8"?>
  90. <resources>
  91. <string name="app_name">App</string>
  92. </resources>
  93. XML
  94. File.write(@strings_file_path, content)
  95. end
  96. 1 def load_existing_strings
  97. 24 return unless File.exist?(@strings_file_path)
  98. begin
  99. 1 doc = Nokogiri::XML(File.read(@strings_file_path))
  100. # Load all existing strings into cache
  101. 1 doc.xpath('//string').each do |string_node|
  102. 1 name = string_node['name']
  103. 1 value = unescape_xml_text(string_node.text)
  104. 1 @strings_cache[name] = value if name && value
  105. end
  106. rescue => e
  107. puts "Warning: Could not parse strings.xml: #{e.message}"
  108. end
  109. end
  110. 1 def find_existing_string(text)
  111. # Exact match
  112. @strings_cache.find { |name, value| value == text }&.first
  113. end
  114. 1 def generate_string_name(text)
  115. # Generate a meaningful name from the text
  116. base_name = text.downcase
  117. .gsub(/[^a-z0-9\s_-]/, '') # Remove special characters
  118. .gsub(/-/, '_') # Replace hyphens with underscores
  119. .gsub(/\s+/, '_') # Replace spaces with underscores
  120. .gsub(/_+/, '_') # Remove duplicate underscores
  121. .gsub(/^_|_$/, '') # Remove leading/trailing underscores
  122. # Handle reserved words
  123. reserved_words = ['default', 'public', 'private', 'protected', 'static',
  124. 'final', 'abstract', 'class', 'interface', 'enum',
  125. 'package', 'import', 'return', 'if', 'else', 'switch',
  126. 'case', 'break', 'continue', 'for', 'while', 'do',
  127. 'try', 'catch', 'finally', 'throw', 'throws', 'new',
  128. 'this', 'super', 'extends', 'implements', 'void',
  129. 'boolean', 'int', 'long', 'float', 'double', 'char',
  130. 'byte', 'short', 'true', 'false', 'null']
  131. if reserved_words.include?(base_name)
  132. base_name = "str_#{base_name}"
  133. end
  134. # Limit length
  135. base_name = base_name[0..30] if base_name.length > 30
  136. # Ensure it starts with a letter
  137. base_name = "str_#{base_name}" unless base_name.match?(/^[a-z]/)
  138. # Handle empty or invalid names
  139. base_name = "str_text" if base_name.empty?
  140. # Make unique if needed
  141. final_name = base_name
  142. counter = 2
  143. while @strings_cache.key?(final_name) || @new_strings.key?(final_name)
  144. final_name = "#{base_name}_#{counter}"
  145. counter += 1
  146. end
  147. final_name
  148. end
  149. 1 def escape_xml_text(text)
  150. # Escape special characters for XML
  151. text.gsub('&', '&amp;')
  152. .gsub('<', '&lt;')
  153. .gsub('>', '&gt;')
  154. .gsub('"', '&quot;')
  155. .gsub("'", '&apos;')
  156. end
  157. 1 def unescape_xml_text(text)
  158. # Unescape XML entities
  159. 1 text.gsub('&amp;', '&')
  160. .gsub('&lt;', '<')
  161. .gsub('&gt;', '>')
  162. .gsub('&quot;', '"')
  163. .gsub('&apos;', "'")
  164. end
  165. end
  166. end
  167. end

lib/xml/xml_builder.rb

77.78% lines covered

135 relevant lines. 105 lines covered and 30 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 require 'json'
  3. 1 require 'fileutils'
  4. 1 require_relative '../core/config_manager'
  5. 1 require_relative '../core/project_finder'
  6. 1 require_relative '../core/attribute_validator'
  7. 1 require_relative '../core/binding_validator'
  8. 1 require_relative 'xml_generator'
  9. 1 module KjuiTools
  10. 1 module Xml
  11. 1 class XmlBuilder
  12. 1 attr_accessor :validation_enabled, :validation_callback
  13. 1 def initialize(config = nil)
  14. 11 @config = config || Core::ConfigManager.load_config
  15. 11 Core::ProjectFinder.setup_paths
  16. # Use current directory as project path (where kjui.config.json is located)
  17. 11 @project_path = Dir.pwd
  18. 11 @layouts_dir = File.join(@project_path, @config['source_directory'] || 'src/main', @config['layouts_directory'] || 'assets/Layouts')
  19. 11 @output_dir = File.join(@project_path, @config['source_directory'] || 'src/main', 'res/layout')
  20. 11 @generated_count = 0
  21. 11 @failed_count = 0
  22. 11 @skipped_count = 0
  23. 11 @validation_enabled = false
  24. 11 @validation_callback = nil
  25. 11 @validator = nil
  26. 11 @binding_validator = nil
  27. end
  28. 1 def build(options = {})
  29. 7 puts "🔨 Building XML View files..."
  30. 7 puts "📁 Project: #{@project_path}"
  31. 7 puts "📂 Layouts: #{@layouts_dir}"
  32. 7 puts "📂 Output: #{@output_dir}"
  33. 7 puts "-" * 60
  34. 7 unless Dir.exist?(@layouts_dir)
  35. 1 puts "❌ Layouts directory not found: #{@layouts_dir}"
  36. 1 return false
  37. end
  38. # Clean output directory if requested
  39. 6 if options[:clean]
  40. 1 clean_output_directory
  41. end
  42. # Ensure output directory exists
  43. 6 FileUtils.mkdir_p(@output_dir)
  44. # Initialize validators if validation is enabled
  45. 6 @validator = Core::AttributeValidator.new(:xml) if @validation_enabled
  46. 6 @binding_validator = Core::BindingValidator.new if @validation_enabled
  47. # Get all JSON files (excluding Resources folder)
  48. 6 json_files = Dir.glob(File.join(@layouts_dir, '*.json'))
  49. # Also get JSON files from subdirectories, but exclude Resources
  50. 6 json_files += Dir.glob(File.join(@layouts_dir, '**/*.json')).reject do |file|
  51. 4 file.include?('/Resources/')
  52. end
  53. 6 json_files.uniq!
  54. 6 if json_files.empty?
  55. 2 puts "⚠️ No JSON files found in #{@layouts_dir}"
  56. 2 return true
  57. end
  58. 4 puts "📄 Found #{json_files.length} JSON files"
  59. 4 puts "-" * 60
  60. # Extract resources before processing layouts
  61. 4 require_relative '../core/resources_manager'
  62. 4 resources_manager = Core::ResourcesManager.new(@config, @project_path)
  63. 4 resources_manager.extract_resources(json_files)
  64. 4 puts "-" * 60
  65. # Process each file
  66. 4 json_files.each do |json_file|
  67. 4 process_layout(json_file, options)
  68. end
  69. # Print summary
  70. 4 puts "-" * 60
  71. 4 puts "✅ Build Complete!"
  72. 4 puts " Generated: #{@generated_count} files"
  73. 4 puts " Failed: #{@failed_count} files" if @failed_count > 0
  74. 4 puts " Skipped: #{@skipped_count} files" if @skipped_count > 0
  75. 4 @failed_count == 0
  76. end
  77. 1 private
  78. 1 def clean_output_directory
  79. 1 puts "🧹 Cleaning output directory..."
  80. 1 if Dir.exist?(@output_dir)
  81. # Only remove generated XML files (those with our comment marker)
  82. 1 Dir.glob(File.join(@output_dir, '*.xml')).each do |file|
  83. 1 content = File.read(file)
  84. 1 if content.include?('<!-- Generated from') && content.include?('.json')
  85. 1 puts " Removing: #{File.basename(file)}"
  86. 1 File.delete(file)
  87. end
  88. end
  89. end
  90. end
  91. # Validate a JSON component and all its children recursively
  92. # @param json_data [Hash] The JSON component to validate
  93. # @param parent_orientation [String, nil] The orientation of the parent component
  94. 1 def validate_json(json_data, parent_orientation = nil)
  95. 1 return [] unless json_data.is_a?(Hash)
  96. 1 warnings = @validator.validate(json_data, nil, parent_orientation)
  97. # Get this component's orientation for passing to children
  98. 1 current_orientation = json_data['orientation']
  99. # Validate children recursively
  100. 1 children = json_data['child'] || json_data['children'] || []
  101. 1 children = [children] unless children.is_a?(Array)
  102. 1 children.each do |child|
  103. warnings.concat(validate_json(child, current_orientation)) if child.is_a?(Hash)
  104. end
  105. # Validate sections (for Collection/Table)
  106. # Section headers, footers, and cells are top-level components, so parent_orientation is nil
  107. 1 if json_data['sections'].is_a?(Array)
  108. json_data['sections'].each do |section|
  109. if section.is_a?(Hash)
  110. ['header', 'footer', 'cell'].each do |key|
  111. warnings.concat(validate_json(section[key], nil)) if section[key].is_a?(Hash)
  112. end
  113. end
  114. end
  115. end
  116. 1 warnings
  117. end
  118. 1 def process_layout(json_file, options = {})
  119. 4 layout_name = File.basename(json_file, '.json')
  120. # Skip partial/included files (convention: starts with underscore)
  121. 4 if layout_name.start_with?('_')
  122. 1 puts " ⏭️ Skipping partial: #{layout_name}"
  123. 1 @skipped_count += 1
  124. 1 return
  125. end
  126. # Skip cell templates (they're used in collections)
  127. 3 if layout_name.end_with?('_cell') || layout_name.include?('cell')
  128. 1 puts " ⏭️ Skipping cell template: #{layout_name}"
  129. 1 @skipped_count += 1
  130. 1 return
  131. end
  132. # Skip included files (used by include mechanism)
  133. 2 if layout_name.start_with?('included')
  134. puts " ⏭️ Skipping include file: #{layout_name}"
  135. @skipped_count += 1
  136. return
  137. end
  138. 2 print " 📝 Processing: #{layout_name}..."
  139. begin
  140. # Validate JSON if enabled
  141. 2 if @validation_enabled && @validator
  142. 1 json_content = File.read(json_file)
  143. 1 json_data = JSON.parse(json_content)
  144. 1 warnings = validate_json(json_data)
  145. 1 if warnings.any?
  146. 1 puts " ⚠️ #{warnings.length} attribute warning(s)"
  147. 1 @validation_callback&.call(layout_name, warnings)
  148. end
  149. # Validate bindings for business logic
  150. 1 if @binding_validator
  151. 1 binding_warnings = @binding_validator.validate(json_data, layout_name)
  152. 1 if binding_warnings.any?
  153. puts " ⚠️ #{binding_warnings.length} binding warning(s)"
  154. @validation_callback&.call(layout_name, binding_warnings)
  155. end
  156. end
  157. end
  158. # Ensure project_path is set in config
  159. 2 config_with_path = @config.merge('project_path' => @project_path)
  160. # Generate XML using the existing generator
  161. 2 generator = XmlGenerator::Generator.new(layout_name, config_with_path)
  162. 2 if generator.generate
  163. 2 @generated_count += 1
  164. 2 puts " ✅" unless @validation_enabled && warnings&.any?
  165. else
  166. @failed_count += 1
  167. puts " ❌"
  168. end
  169. rescue JSON::ParserError => e
  170. @failed_count += 1
  171. puts " ❌"
  172. puts " JSON Parse Error: #{e.message}"
  173. rescue => e
  174. @failed_count += 1
  175. puts " ❌"
  176. puts " Error: #{e.message}"
  177. puts e.backtrace.first(5).map { |line| " #{line}" }.join("\n")
  178. end
  179. end
  180. end
  181. end
  182. end
  183. # Allow running directly
  184. 1 if __FILE__ == $0
  185. require_relative '../core/config_manager'
  186. config = KjuiTools::Core::ConfigManager.load_config
  187. builder = KjuiTools::Xml::XmlBuilder.new(config)
  188. options = {}
  189. ARGV.each do |arg|
  190. case arg
  191. when '--clean', '-c'
  192. options[:clean] = true
  193. when '--debug', '-d'
  194. config['debug'] = true
  195. when '--validate', '-v'
  196. builder.validation_enabled = true
  197. end
  198. end
  199. builder.build(options)
  200. end

lib/xml/xml_generator.rb

74.16% lines covered

209 relevant lines. 155 lines covered and 54 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 require 'json'
  3. 1 require 'fileutils'
  4. 1 require 'set'
  5. 1 require 'nokogiri'
  6. 1 require_relative '../core/json_loader'
  7. 1 require_relative '../core/style_loader'
  8. 1 require_relative 'helpers/component_mapper'
  9. 1 require_relative 'helpers/attribute_mapper'
  10. 1 require_relative 'helpers/binding_parser'
  11. 1 require_relative 'helpers/layout_attribute_processor'
  12. 1 require_relative 'helpers/data_binding_helper'
  13. 1 require_relative 'drawable/drawable_generator'
  14. 1 require_relative 'resources/string_resource_manager'
  15. 1 module XmlGenerator
  16. 1 class Generator
  17. 1 def initialize(layout_name, config, options = {})
  18. 24 @layout_name = layout_name
  19. 24 @config = config
  20. 24 @options = options
  21. 24 @json_loader = JsonLoader.new(config)
  22. 24 @style_loader = StyleLoader.new(config)
  23. 24 @component_mapper = ComponentMapper.new
  24. # Initialize resource managers
  25. 24 project_root = @config['project_path']
  26. 24 @drawable_generator = DrawableGenerator::Generator.new(project_root)
  27. 24 @string_resource_manager = Resources::StringResourceManager.new(project_root)
  28. 24 @attribute_mapper = AttributeMapper.new(@drawable_generator, @string_resource_manager)
  29. 24 @binding_parser = BindingParser.new
  30. 24 @layout_processor = LayoutAttributeProcessor.new(@attribute_mapper)
  31. # Get package name from config or auto-detect
  32. 24 @package_name = @config['package_name'] || detect_package_name
  33. # Allow custom output filename
  34. 24 @output_filename = options[:output_filename]
  35. end
  36. 1 def generate
  37. 4 puts "Generating XML for #{@layout_name}..."
  38. # Load JSON
  39. 4 json_content = @json_loader.load_layout(@layout_name)
  40. 4 if json_content.nil?
  41. 1 puts "Error: Could not load layout #{@layout_name}"
  42. 1 return false
  43. end
  44. # Parse JSON
  45. 3 layout_data = JSON.parse(json_content)
  46. # Apply styles
  47. 3 layout_data = @style_loader.apply_styles(layout_data)
  48. # Generate XML
  49. 3 xml_content = generate_xml(layout_data)
  50. # Save XML file
  51. 3 save_xml(xml_content)
  52. # Save any new strings to strings.xml
  53. 3 @string_resource_manager.save_new_strings
  54. 3 true
  55. rescue => e
  56. puts "Error generating XML: #{e.message}"
  57. puts " Backtrace:"
  58. e.backtrace[0..4].each { |line| puts " #{line}" }
  59. false
  60. end
  61. 1 private
  62. 1 def detect_package_name
  63. # Try to detect from AndroidManifest.xml
  64. manifest_paths = [
  65. File.join(@config['project_path'], 'src', 'main', 'AndroidManifest.xml'),
  66. File.join(@config['project_path'], 'app', 'src', 'main', 'AndroidManifest.xml')
  67. ]
  68. manifest_paths.each do |path|
  69. if File.exist?(path)
  70. content = File.read(path)
  71. if content =~ /package="([^"]+)"/
  72. return $1
  73. end
  74. end
  75. end
  76. # Try to detect from build.gradle
  77. gradle_paths = [
  78. File.join(@config['project_path'], 'build.gradle'),
  79. File.join(@config['project_path'], 'app', 'build.gradle'),
  80. File.join(@config['project_path'], 'build.gradle.kts'),
  81. File.join(@config['project_path'], 'app', 'build.gradle.kts')
  82. ]
  83. gradle_paths.each do |path|
  84. if File.exist?(path)
  85. content = File.read(path)
  86. # Look for namespace
  87. if content =~ /namespace\s*[=:]\s*["']([^"']+)["']/
  88. return $1
  89. end
  90. # Look for applicationId
  91. if content =~ /applicationId\s*[=:]\s*["']([^"']+)["']/
  92. return $1
  93. end
  94. end
  95. end
  96. # Default
  97. 'com.example.app'
  98. end
  99. 1 def generate_xml(json_data)
  100. # Check if layout uses data binding
  101. 3 has_binding = check_for_bindings(json_data)
  102. 3 if has_binding
  103. generate_data_binding_xml(json_data)
  104. else
  105. 3 generate_regular_xml(json_data)
  106. end
  107. end
  108. 1 def check_for_bindings(json_data)
  109. # Recursively check for @{} syntax in the JSON
  110. 5 json_string = json_data.to_json
  111. 5 json_string.include?('@{')
  112. end
  113. 1 def generate_data_binding_xml(json_data)
  114. # Extract all binding variables
  115. variables = extract_binding_variables(json_data)
  116. builder = Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
  117. xml.comment " Generated from #{@layout_name}.json with Data Binding "
  118. xml.comment " DO NOT EDIT MANUALLY - Use 'kjui generate' to update "
  119. # Create layout root for data binding
  120. xml.layout('xmlns:android' => 'http://schemas.android.com/apk/res/android',
  121. 'xmlns:app' => 'http://schemas.android.com/apk/res-auto',
  122. 'xmlns:tools' => 'http://schemas.android.com/tools') do
  123. # Add data section
  124. xml.data do
  125. # Add common imports
  126. xml.import(type: 'android.view.View')
  127. # Add data variable
  128. if has_data_definitions?(json_data)
  129. data_class = "#{camelize(@layout_name)}Data"
  130. xml.variable(name: 'data', type: "#{@package_name}.data.#{data_class}")
  131. end
  132. # Add viewModel variable if there are onClick handlers
  133. if has_click_handlers?(json_data)
  134. view_model_class = "#{camelize(@layout_name)}ViewModel"
  135. xml.variable(name: 'viewModel', type: "#{@package_name}.viewmodels.#{view_model_class}")
  136. end
  137. end
  138. # Add the actual layout content
  139. # Pass false for is_root since namespaces are already on <layout> tag
  140. create_xml_element(xml, json_data, false)
  141. end
  142. end
  143. # Format the XML nicely
  144. doc = Nokogiri::XML(builder.to_xml) do |config|
  145. config.default_xml.noblanks
  146. end
  147. # Pretty print with proper indentation
  148. formatted_xml = doc.to_xml(
  149. indent: 4,
  150. indent_text: ' ',
  151. save_with: Nokogiri::XML::Node::SaveOptions::FORMAT |
  152. Nokogiri::XML::Node::SaveOptions::AS_XML
  153. )
  154. # Additional formatting: put each attribute on a new line for better readability
  155. format_attributes(formatted_xml)
  156. end
  157. 1 def generate_regular_xml(json_data)
  158. 3 builder = Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
  159. 3 xml.comment " Generated from #{@layout_name}.json "
  160. 3 xml.comment " DO NOT EDIT MANUALLY - Use 'kjui generate' to update "
  161. # Create root layout
  162. 3 create_xml_element(xml, json_data, true)
  163. end
  164. # Format the XML nicely
  165. 3 doc = Nokogiri::XML(builder.to_xml) do |config|
  166. 3 config.default_xml.noblanks
  167. end
  168. # Pretty print with proper indentation
  169. 3 formatted_xml = doc.to_xml(
  170. indent: 4,
  171. indent_text: ' ',
  172. save_with: Nokogiri::XML::Node::SaveOptions::FORMAT |
  173. Nokogiri::XML::Node::SaveOptions::AS_XML
  174. )
  175. # Additional formatting: put each attribute on a new line for better readability
  176. 3 format_attributes(formatted_xml)
  177. end
  178. 1 def extract_binding_variables(json_data)
  179. 2 variables = Set.new
  180. 2 extract_variables_recursive(json_data, variables)
  181. 2 variables
  182. end
  183. 1 def extract_variables_recursive(data, variables)
  184. 5 if data.is_a?(Hash)
  185. 4 data.each do |key, value|
  186. 5 if value.is_a?(String) && value.start_with?('@{')
  187. # Extract variable name from binding expression
  188. 3 if value.match(/@\{([^}]+)\}/)
  189. 3 expr = $1
  190. # Simple variable extraction (can be enhanced)
  191. 3 if expr.match(/^(\w+)/)
  192. 3 variables.add($1)
  193. end
  194. end
  195. 2 elsif value.is_a?(Hash) || value.is_a?(Array)
  196. 1 extract_variables_recursive(value, variables)
  197. end
  198. end
  199. 1 elsif data.is_a?(Array)
  200. 3 data.each { |item| extract_variables_recursive(item, variables) }
  201. end
  202. end
  203. 1 def has_click_handlers?(json_data)
  204. 3 json_string = json_data.to_json
  205. 3 json_string.include?('"onClick"') || json_string.include?('"onclick"')
  206. end
  207. 1 def camelize(snake_case)
  208. 2 snake_case.split('_').map(&:capitalize).join
  209. end
  210. 1 def needs_tools_namespace?(json_element)
  211. # Check if this element or any of its children use tools attributes
  212. 6 json_string = json_element.to_json
  213. 6 json_string.include?('"tools:') || json_string.include?('"title"') || json_string.include?('"count"')
  214. end
  215. 1 def has_data_definitions?(json_data)
  216. # Check if there are any data definitions anywhere in the JSON structure
  217. 3 return true if json_data['data']
  218. # Check children recursively
  219. 2 if json_data['child']
  220. 1 children = json_data['child'].is_a?(Array) ? json_data['child'] : [json_data['child']]
  221. 1 children.each do |child|
  222. 1 return true if child.is_a?(Hash) && child['data']
  223. return true if child.is_a?(Hash) && has_data_definitions?(child)
  224. end
  225. end
  226. 1 if json_data['children']
  227. children = json_data['children'].is_a?(Array) ? json_data['children'] : [json_data['children']]
  228. children.each do |child|
  229. return true if child.is_a?(Hash) && child['data']
  230. return true if child.is_a?(Hash) && has_data_definitions?(child)
  231. end
  232. end
  233. 1 false
  234. end
  235. 1 def create_xml_element(xml, json_element, is_root = false, parent_orientation = nil, parent_type = nil)
  236. # Map JSON type to Android view class (pass json_element for View type checking)
  237. 5 view_class = @component_mapper.map_component(json_element['type'], json_element)
  238. # Prepare all attributes first
  239. 5 attrs = {}
  240. # Add namespace declarations if this is the root element
  241. 5 if is_root
  242. 3 attrs['xmlns:android'] = 'http://schemas.android.com/apk/res/android'
  243. # Always add app namespace as it's commonly needed for ConstraintLayout and custom attributes
  244. 3 attrs['xmlns:app'] = 'http://schemas.android.com/apk/res-auto'
  245. # Add tools namespace if we're using tools attributes
  246. 3 if needs_tools_namespace?(json_element)
  247. attrs['xmlns:tools'] = 'http://schemas.android.com/tools'
  248. end
  249. end
  250. # Add ID if present
  251. 5 if json_element['id']
  252. attrs['android:id'] = "@+id/#{json_element['id']}"
  253. end
  254. # Process layout dimensions
  255. 5 dimension_attrs = @layout_processor.process_dimensions(json_element, is_root, parent_orientation)
  256. 5 attrs.merge!(dimension_attrs)
  257. # Process orientation for LinearLayout
  258. 5 orientation_attrs = @layout_processor.process_orientation(view_class, json_element)
  259. 5 attrs.merge!(orientation_attrs)
  260. # Process all other attributes
  261. 5 other_attrs = @layout_processor.process_attributes(json_element, parent_type)
  262. 5 attrs.merge!(other_attrs)
  263. # Determine orientation for children
  264. 5 current_orientation = nil
  265. 5 if view_class == 'LinearLayout'
  266. current_orientation = json_element['orientation'] || 'vertical'
  267. end
  268. # Create element with attributes
  269. # For custom views with package name, use the full class name
  270. 5 if view_class.include?('.')
  271. # Custom view with package name - create element directly
  272. 5 xml.send(:method_missing, view_class, attrs) do
  273. 5 create_children(xml, json_element, current_orientation, view_class)
  274. end
  275. else
  276. # Standard Android view
  277. xml.send(view_class, attrs) do
  278. create_children(xml, json_element, current_orientation, view_class)
  279. end
  280. end
  281. end
  282. 1 def create_children(parent_element, json_element, parent_orientation = nil, parent_type = nil)
  283. # Handle children
  284. 5 children = json_element['children'] || json_element['child']
  285. 5 return unless children
  286. 3 children = [children] unless children.is_a?(Array)
  287. 3 children.each do |child|
  288. # Skip data definitions - they don't create UI elements
  289. 2 next if child.is_a?(Hash) && child.key?('data') && !child.key?('type')
  290. 2 create_xml_element(parent_element, child, false, parent_orientation, parent_type)
  291. end
  292. end
  293. 1 def format_attributes(xml_string)
  294. # Format XML to put each attribute on its own line for better readability
  295. 6 lines = xml_string.split("\n")
  296. 6 formatted_lines = []
  297. 6 lines.each do |line|
  298. # Skip comments and empty lines
  299. 19 if line.strip.start_with?('<!--') || line.strip.start_with?('<?xml') || line.strip.empty?
  300. 11 formatted_lines << line
  301. 11 next
  302. end
  303. # Check if line contains an XML tag with attributes
  304. 8 if line =~ /^(\s*)<([^\/\s>]+)(.*?)(\s*\/?>.*?)$/
  305. 6 indent = $1
  306. 6 tag_name = $2
  307. 6 attributes_str = $3
  308. 6 tag_end = $4
  309. # Parse all attributes including namespace prefixes
  310. 6 attributes = []
  311. 6 attributes_str.scan(/(\S+?)="([^"]*)"/) do |attr_name, attr_value|
  312. 23 attributes << [attr_name, attr_value]
  313. end
  314. # Format based on number of attributes
  315. 6 if attributes.size > 1
  316. # Multiple attributes - put each on its own line
  317. 5 formatted_lines << "#{indent}<#{tag_name}"
  318. 5 attributes.each do |attr_name, attr_value|
  319. 22 formatted_lines << "#{indent} #{attr_name}=\"#{attr_value}\""
  320. end
  321. # Handle closing tag
  322. 5 if tag_end.strip == '/>'
  323. 3 formatted_lines[-1] += '/>'
  324. 2 elsif tag_end.include?('>')
  325. # Check if there's content after the >
  326. 2 if tag_end =~ />\s*(.+)$/
  327. content = $1
  328. formatted_lines[-1] += '>'
  329. # Add the content on the same line if it's simple text
  330. if content && !content.empty?
  331. formatted_lines[-1] += content
  332. end
  333. else
  334. 2 formatted_lines[-1] += '>'
  335. end
  336. else
  337. formatted_lines[-1] += tag_end.strip
  338. end
  339. 1 elsif attributes.size == 1
  340. # Single attribute - can stay on one line
  341. 1 formatted_lines << line
  342. else
  343. # No attributes
  344. formatted_lines << line
  345. end
  346. else
  347. # Not a tag line or closing tag
  348. 2 formatted_lines << line
  349. end
  350. end
  351. 6 formatted_lines.join("\n")
  352. end
  353. 1 def save_xml(xml_content)
  354. # Determine output path
  355. 3 output_dir = File.join(@config['project_path'], 'src', 'main', 'res', 'layout')
  356. 3 output_dir = File.join(@config['project_path'], 'app', 'src', 'main', 'res', 'layout') if File.exist?(File.join(@config['project_path'], 'app'))
  357. 3 FileUtils.mkdir_p(output_dir)
  358. # Use custom filename if provided, otherwise use default
  359. 3 filename = @output_filename || "#{@layout_name.downcase}.xml"
  360. 3 output_file = File.join(output_dir, filename)
  361. # Save XML file
  362. 3 File.write(output_file, xml_content)
  363. 3 puts "✅ Generated: #{output_file}"
  364. end
  365. end
  366. end